← Back to Tutorials

Companion to the book The Chat Templates Handbook · this part maps to Book ch. 3

TutorialFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

How to Render a Chat Template with Jinja Like Transformers

Rebuild the Jinja environment apply_chat_template uses, watch one missing dash add a token to every assistant turn, and prove your local render matches Qwen3 byte for byte.

#tutorial#intermediate#chat-templates#jinja2#apply-chat-template#transformers#qwen3#whitespace

What you'll build

If you render a chat template with plain jinja2.Environment(), you are not running the program Transformers runs. This is an intermediate tutorial for Python engineers who already call apply_chat_template. By the end you will have templatestudio/jinja_env.py, a module that renders a chat template string the way Transformers 5.22.1 does, with no model weights and no GPU, and a script, prove_faithful.py, that proves it byte for byte on Qwen3-0.6B. You will also know which Jinja setting causes each difference in the table below, so you can read a template's whitespace tags and predict what they write.

Verified against Python 3.12.2, transformers==5.22.1, tokenizers==0.23.3, huggingface-hub==2.4.0, jinja2==3.1.6 on 2026-11-09. The claims about Gemma 3's template are quoted from the cited template, not run. Re-run prove_faithful.py whenever you upgrade Transformers.

Here are ten renders, six with Qwen3's template and four with small templates of your own, each compared with apply_chat_template:

text
case                     vanilla  bare   faithful  tokenssupport chat             True     True   True      Truesupport chat, no prompt  True     True   True      Truewith a tool schema       False    True   True      Truetool round trip          True     True   True      Truethinking off             True     True   True      Truereasoning field          False    False  True      Truereadable ChatML          False    True   True      Truehelpers and extensions   error    True   True      Truetests tools is defined   False    False  True      Truereads eos_token          False    False  True      Truedrifted conversation refused: message 3: unknown role 'User'
  • vanilla is plain jinja2.Environment().
  • bare uses Transformers' Jinja settings, with only your own variables.
  • faithful is render_template_string, the function you build here.
  • tokens checks that the faithful string also encodes to the same token IDs.

True means byte-identical, and error means the template did not even compile. The string can differ in whitespace, in how tool schemas are written, in which helpers and tags exist, and in which variables exist. A plain environment fails six of the ten cases, even though Qwen3's template strips its own whitespace. The right settings alone still miss three. Only faithful matches every case, down to the token IDs.

Once you can render a template locally and trust the result, you can change it and see exactly what moved. Delete one - from Qwen3's template and every assistant turn gains a token:

text
tokens: 59 -> 60 | first divergence at 42correct: 151645 '<|im_end|>'broken:  260 '        '

It is Part 3 of a series and continues the project from Part 2, How to Validate Chat Messages Before apply_chat_template. It takes about 25 minutes, needs no API key, and downloads nothing beyond the Qwen3 tokenizer files from Part 1.

Prerequisites: transformers 5.22.1, jinja2 3.1.6 and the Part 2 project

You need the project from Parts 1 and 2: the chat-templates-part1/ folder with its virtual environment, naive.py, drift.py, tool_turns.py, and the templatestudio/ package with __init__.py, messages.py and render.py. This part adds one module, templatestudio/jinja_env.py, plus a template file, two data files and ten scripts. It changes no existing file. If you do not have the project, build it in three moves:

  • Follow Part 1's Prerequisites to create the folder and the environment, and copy naive.py from Part 1's complete code.
  • Create the templatestudio/ folder with an empty __init__.py in it, if it is not there yet.
  • Copy drift.py, tool_turns.py, templatestudio/messages.py and templatestudio/render.py from Part 2's complete code. Take render.py from Part 2, because Part 2 changed it.

Go to the project folder, activate the environment, and turn off the advisory that Transformers prints when PyTorch is not installed. Run the cd from the folder that contains chat-templates-part1/, and skip it if you are already inside the project:

bash
cd chat-templates-part1source .venv/bin/activateexport TRANSFORMERS_NO_ADVISORY_WARNINGS=1

On Windows, activate with .venv\Scripts\activate in PowerShell or source .venv/Scripts/activate in Git Bash, and set the variable in PowerShell with $env:TRANSFORMERS_NO_ADVISORY_WARNINGS = "1". Every script in this part runs from the project root.

The versions are unchanged from Parts 1 and 2, so if you are continuing from there they are already installed. Otherwise install them inside the activated environment:

bash
pip install "transformers==5.22.1" "tokenizers==0.23.3" \    "huggingface-hub==2.4.0" "jinja2==3.1.6"

In PowerShell, put the install on one line. Jinja2 is pinned explicitly for two reasons: Transformers does not install it for you, and 3.1.6 is the first release with both of the sandbox fixes described in step 2.

This part copies the Jinja setup of Transformers 5.22.1, the version the series pins. Transformers 5.22.2, released on 2026-11-06, changes exactly this code to harden the sandbox. This tutorial was verified on 5.22.1 only, and the last section explains what 5.22.2 adds.

The series needs Python 3.11 or later, because Part 2's messages.py imports typing.NotRequired, and it was verified on 3.12.2. Check the Python version, the install, and Part 2's module before you start:

bash
python -c "import sys; print(sys.version_info >= (3, 11))"python -c "import transformers as t, jinja2 as j; print(t.__version__, j.__version__)"python -c "from templatestudio.messages import assert_valid; print(assert_valid.__name__)"python -c "from templatestudio.render import load_tokenizer as load; \print(len(load('Qwen/Qwen3-0.6B').chat_template))"python -c "import naive, drift, tool_turns; print('data files ok')"

Expected output:

text
True5.22.1 3.1.6assert_valid4168data files ok

If a command prints ModuleNotFoundError, you are not in the project root, or one of the six files above is missing: the error names it. The fourth command loads Qwen3's tokenizer, which every step uses, and prints the length of its chat template, 4168 characters. If you ran Part 1, the tokenizer comes from the local cache. Otherwise the first run downloads the tokenizer files, about 16 MB, from the Hugging Face Hub, which needs network access but no account. In PowerShell, type that command on one line.

How Jinja turns a chat template into the wire format

A chat template is Jinja source code, and on its own it is only text. It produces a string when an environment compiles it and a render call passes it variables, so the source, the environment and the variables all decide the output. Parts 1 and 2 only ever saw the result, because apply_chat_template keeps the environment and the variables inside Transformers. Part 2 promised just enough Jinja to read a template. This part covers the environment, the variables, and the first piece of Jinja syntax you need: the whitespace tags in step 6. Part 4 then reads whole templates line by line. The argument for why this layer deserves your attention is in Chat Templates: The Last Unowned Layer in Your LLM Stack, and this tutorial is its hands-on half.

d2
direction: down
source: "Template source\nthe Jinja text in tokenizer_config.json" {
  style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
env: "Environment (steps 2-5)\nsandbox · trim_blocks · lstrip_blocks\ntojson · raise_exception · strftime_now\nloopcontrols · {% generation %}" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
inputs: "Variables (steps 8-10)\nmessages · add_generation_prompt\ntools=None · documents=None\neos_token · pad_token · your kwargs" {
  style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}
}
out: "Wire format string\nthe bytes the tokenizer encodes" {
  style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
source -> env: "1. compiled by"
inputs -> env: "2. passed to render()"
env -> out: "3. one string"

The purple box is where local renders usually go wrong. Jinja's defaults keep the line breaks around block tags, use a JSON filter that sorts keys, and know none of the helpers a chat template calls. The orange box is the second trap: Transformers passes tools and documents even when you give it none, and it adds the tokenizer's special tokens. The Chat Templates Handbook, the book this series accompanies, has a name for an environment that matches the target runtime in all of these ways: the faithful environment. Render anywhere else and the string you inspect is not the string your model receives.

Step 1: Render a chat template with plain jinja2.Environment

Goal. Render a readable ChatML template with Jinja's defaults and compare it with what Qwen3 produces for the same conversation.

Why this step. Template authors put {% for %} and {% if %} on their own indented lines so that a person can read the logic. Whether those lines leave newlines and spaces in the output is decided by the environment, and the template has no say in it. A small template that you can read in full shows you this difference before you fix it.

Create readable_chatml.jinja in the project root. It writes ChatML, the format Qwen3 uses, for system, user and assistant turns:

jinja
{% for message in messages %}    {% if message['role'] in ['system', 'user', 'assistant'] %}<|im_start|>{{ message['role'] }}{{ message['content'] }}<|im_end|>    {% endif %}{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant{% endif %}

Then create show_vanilla.py. It renders the template with jinja2.Environment() and compares the result with Part 1's render, which calls apply_chat_template. render passes add_generation_prompt=True by default, so the local render passes it too. Part 1's visible_whitespace prints each space as · and each newline as \n followed by a real line break:

python
# show_vanilla.pyimport sysfrom pathlib import Pathimport jinja2from naive import CONVERSATIONfrom templatestudio.render import (    first_divergence,    load_tokenizer,    render,    visible_whitespace,)sys.stdout.reconfigure(encoding="utf-8")source = Path("readable_chatml.jinja").read_text(encoding="utf-8")local = jinja2.Environment().from_string(source).render(    messages=CONVERSATION, add_generation_prompt=True)production = render(load_tokenizer("Qwen/Qwen3-0.6B"), CONVERSATION)print("local:", len(local), "characters; Qwen3:", len(production))print("first divergence at character", first_divergence(local, production))print("--- first 8 lines, local render:")lines = visible_whitespace(local).splitlines(keepends=True)print("".join(lines[:8]), end="")

Run it.

bash
python show_vanilla.py

Expected output.

text
local: 338 characters; Qwen3: 292first divergence at character 0--- first 8 lines, local render:\n····\n<|im_start|>system\nYou·are·a·terse·support·agent.·Answer·in·one·sentence.<|im_end|>\n····\n\n····\n<|im_start|>user\n

What just happened. The strings differ at the very first character. The local render is 46 characters longer, and all 46 are whitespace: 14 newlines and 32 spaces. visible_whitespace from Part 1 shows where they come from. Each line that holds only a block tag, such as {% for %} or the indented {% if %}, still writes its line break, and the indented lines also write their four spaces. Remove those 46 characters and the two strings are identical, but as rendered, every turn starts with whitespace tokens that Qwen3's own template never writes there.

Step 2: Turn on trim_blocks and lstrip_blocks in a sandboxed Jinja environment

Goal. Create templatestudio/jinja_env.py with a build_template_env that sets the two whitespace options Transformers sets, and re-render the template.

Why this step. Jinja has two options for exactly the layout in step 1. trim_blocks=True removes the first newline after a block tag. lstrip_blocks=True removes spaces and tabs from the start of a line up to a block tag, provided nothing else comes before the tag on that line. Transformers turns on both. It also builds the environment from ImmutableSandboxedEnvironment, Jinja's locked-down class, because a chat template is code you downloaded with a model. In that sandbox a template cannot call methods that change a list or a dict, so rendering cannot modify the conversation you validated in Part 2.

An unsandboxed environment is not a theoretical risk: the ChatML post covers CVE-2024-34359, where llama-cpp-python rendered the template embedded in a GGUF model file in an unsandboxed jinja2.Environment. The sandbox has had two escapes of its own, CVE-2024-56326 and CVE-2025-27516, fixed in Jinja2 3.1.5 and 3.1.6. That is why this series pins 3.1.6.

d2
grid-rows: 1
grid-gap: 40
plain: "jinja2.Environment()" {
  label.near: top-center
  grid-rows: 2
  grid-gap: 16
  line: "····{% if ... %} + newline" {style: {fill: "#95A5A6"; stroke: "#6F7C7D"; font-color: "#2C2C2A"}}
  kept: "writes ···· + newline\n4 spaces and a line break" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
faithful: "trim_blocks + lstrip_blocks" {
  label.near: top-center
  grid-rows: 2
  grid-gap: 16
  line: "····{% if ... %} + newline" {style: {fill: "#95A5A6"; stroke: "#6F7C7D"; font-color: "#2C2C2A"}}
  gone: "writes nothing\nlstrip removes ····, trim removes the newline" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}

The grey cell is the same template line in both columns: four spaces, a block tag, a line break. On the left Jinja writes the spaces and the break. On the right lstrip_blocks removes the spaces before the tag and trim_blocks removes the break after it, so the line writes nothing.

Create templatestudio/jinja_env.py:

python
# templatestudio/jinja_env.pyfrom __future__ import annotationsfrom jinja2.sandbox import ImmutableSandboxedEnvironmentdef build_template_env() -> ImmutableSandboxedEnvironment:    """A Jinja environment configured like the one Transformers 5.22.1 uses."""    env = ImmutableSandboxedEnvironment(        trim_blocks=True,        lstrip_blocks=True,    )    return env

Create show_faithful.py. It is step 1's comparison with the new environment:

python
# show_faithful.pyfrom pathlib import Pathfrom naive import CONVERSATIONfrom templatestudio.jinja_env import build_template_envfrom templatestudio.render import first_divergence, load_tokenizer, rendersource = Path("readable_chatml.jinja").read_text(encoding="utf-8")local = build_template_env().from_string(source).render(    messages=CONVERSATION, add_generation_prompt=True)production = render(load_tokenizer("Qwen/Qwen3-0.6B"), CONVERSATION)print("local:", len(local), "characters; Qwen3:", len(production))print("first divergence at character", first_divergence(local, production))

Run it.

bash
python show_faithful.py

Expected output.

text
local: 292 characters; Qwen3: 292first divergence at character None

What just happened. lstrip_blocks removed the 32 spaces and trim_blocks removed the 14 newlines that step 1 counted, so your readable template now produces the same 292 characters as Qwen3's own template. first_divergence returns None when two strings are equal. You changed only the environment around the template, and that is the point of this tutorial: you cannot judge a template's whitespace without knowing the environment that will render it. You can see the sandbox refuse something in the troubleshooting table, where a template that tries to append to messages gets a SecurityError.

From the next step on you render Qwen3's own template, which tokenizer.chat_template holds as a string. Every tag in it opens with {{- or {%-, so even with both whitespace options switched off, the unmodified template gives the same string for every conversation in this tutorial. The differences that remain have other causes.

Step 3: Override the tojson filter (sort_keys, ensure_ascii) so tool schemas render like Transformers

Goal. Switch from your readable template to Qwen3's real one, render it with a tool schema, find the difference, and fix it with Transformers' own tojson filter.

Why this step. As soon as you pass tools, Qwen3's template renders differently in your environment, because it writes each tool schema with the tojson filter. A filter is a Python function that a template applies with a pipe: {{ value | tojson(indent=2) }} calls tojson(value, indent=2), and env.filters maps the name to the function. Jinja's built-in tojson is made for HTML pages. It sorts the keys, and it replaces <, >, & and ' with \u escapes so the JSON is safe inside a <script> tag.

Transformers replaces the filter with a plain json.dumps that keeps your key order and escapes no HTML characters, and that plain version is what Qwen3's template gets in production. Jinja documents its own behavior in the tojson filter reference and the json.dumps_kwargs policy, whose default is sort_keys=True.

d2
grid-rows: 1
grid-gap: 40
builtin: "Jinja's built-in tojson" {
  label.near: top-center
  grid-rows: 3
  grid-gap: 14
  a: "sort_keys=True" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  b: "' becomes \\u0027\n< becomes \\u003c" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
  c: "accepts indent only" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
hf: "Transformers' tojson" {
  label.near: top-center
  grid-rows: 3
  grid-gap: 14
  a: "your key order" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  b: "characters as written\nensure_ascii=False" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
  c: "indent, separators,\nsort_keys, ensure_ascii" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}

Each row compares one behavior. The last row matters for templates that call the filter with arguments, such as tojson(ensure_ascii=False): the built-in filter rejects any argument except indent.

Create order_tools.py with one tool schema. The description contains an apostrophe and angle brackets on purpose:

python
# order_tools.py# One tool schema, in the JSON shape apply_chat_template accepts.TOOLS = [    {        "type": "function",        "function": {            "name": "track_order",            "description": "Look up an order's status, e.g. <shipped>.",            "parameters": {                "type": "object",                "properties": {"order_id": {"type": "string"}},                "required": ["order_id"],            },        },    }]

Create show_tojson.py. It renders Qwen3's template with the tool schema and prints the tool schema line from both renders:

python
# show_tojson.pyimport textwrapfrom naive import CONVERSATIONfrom order_tools import TOOLSfrom templatestudio.jinja_env import build_template_envfrom templatestudio.render import first_divergence, load_tokenizertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")local = build_template_env().from_string(tokenizer.chat_template).render(    messages=CONVERSATION, tools=TOOLS, add_generation_prompt=True)production = tokenizer.apply_chat_template(    CONVERSATION, tools=TOOLS, tokenize=False, add_generation_prompt=True)print("first divergence at character", first_divergence(local, production))for name, text in [("local", local), ("Qwen3", production)]:    schema_line = next(line for line in text.splitlines() if line.startswith("{"))    print(f"--- {name}, the tool schema line:")    print(textwrap.fill(schema_line, 72))

Run it before you change the environment:

bash
python show_tojson.py

Expected output (before the change).

text
first divergence at character 236--- local, the tool schema line:{"function": {"description": "Look up an order\u0027s status, e.g.\u003cshipped\u003e.", "name": "track_order", "parameters":{"properties": {"order_id": {"type": "string"}}, "required":["order_id"], "type": "object"}}, "type": "function"}--- Qwen3, the tool schema line:{"type": "function", "function": {"name": "track_order", "description":"Look up an order's status, e.g. <shipped>.", "parameters": {"type":"object", "properties": {"order_id": {"type": "string"}}, "required":["order_id"]}}}

The local line starts with "function" because sorting puts it before "type", and the apostrophe and angle brackets came out as escapes. Now add the filter. In templatestudio/jinja_env.py, add import json below the __future__ import, add the tojson function above build_template_env, and register it in build_template_env. The file now reads:

python
# templatestudio/jinja_env.pyfrom __future__ import annotationsimport jsonfrom jinja2.sandbox import ImmutableSandboxedEnvironmentdef tojson(    x: object,    ensure_ascii: bool = False,    indent: int | None = None,    separators: tuple[str, str] | None = None,    sort_keys: bool = False,) -> str:    """Transformers' JSON filter: key order kept, nothing HTML-escaped."""    return json.dumps(        x,        ensure_ascii=ensure_ascii,        indent=indent,        separators=separators,        sort_keys=sort_keys,    )def build_template_env() -> ImmutableSandboxedEnvironment:    """A Jinja environment configured like the one Transformers 5.22.1 uses."""    env = ImmutableSandboxedEnvironment(        trim_blocks=True,        lstrip_blocks=True,    )    env.filters["tojson"] = tojson    return env

Run it.

bash
python show_tojson.py

Expected output.

text
first divergence at character None--- local, the tool schema line:{"type": "function", "function": {"name": "track_order", "description":"Look up an order's status, e.g. <shipped>.", "parameters": {"type":"object", "properties": {"order_id": {"type": "string"}}, "required":["order_id"]}}}--- Qwen3, the tool schema line:{"type": "function", "function": {"name": "track_order", "description":"Look up an order's status, e.g. <shipped>.", "parameters": {"type":"object", "properties": {"order_id": {"type": "string"}}, "required":["order_id"]}}}

What just happened. The whole render, tools block included, is now identical to Qwen3's. Before the change, every tool schema reached the tokenizer with a different key order and with escapes in place of ' and <, so the model would have read different token IDs for the same tool on every request. Setting env.filters["tojson"] replaces the built-in filter of the same name for every template this environment compiles. The signature matches Transformers' filter argument for argument, so a template that calls tojson(indent=2) or tojson(ensure_ascii=False) works here exactly as it does in production.

Step 4: Add raise_exception and strftime_now as Jinja globals

Goal. Give templates the two functions Transformers injects, so a template can reject its input and stamp the date.

Why this step. Jinja has no raise statement, so a template that wants to refuse a conversation needs a function to call. Transformers provides raise_exception(message), which raises jinja2.exceptions.TemplateError with the template's message. Gemma 3's template uses it to reject conversations that do not alternate between user and assistant. Transformers also provides strftime_now(format), because some templates write today's date into the system turn. If your environment lacks either name, a template that uses it fails locally with an error that production never raises.

d2
direction: down
tpl: "template calls\nraise_exception('...') or strftime_now('%Y-%m-%d')" {
  style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
lookup: "env.globals lookup" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
miss: "name missing\nUndefinedError: 'raise_exception' is undefined" {
  style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
}
hit: "name registered\nTemplateError with the template's message,\nor today's date" {
  style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
}
tpl -> lookup
lookup -> miss: "before this step"
lookup -> hit: "after this step"

The red and green boxes are the two outputs of show_helpers.py below, before and after the change. The green path is what production does: the template's own message reaches you as the error.

Create show_helpers.py. GUARD is a template that requires a system message first, and DATED stamps the date:

python
# show_helpers.pyimport textwrapfrom templatestudio.jinja_env import build_template_envGUARD = (    "{% if messages[0]['role'] != 'system' %}"    "{{ raise_exception('the first message must be a system message') }}"    "{% endif %}system message present")DATED = "Today is {{ strftime_now('%Y-%m-%d') }}."env = build_template_env()for name, source in [("GUARD", GUARD), ("DATED", DATED)]:    try:        output = env.from_string(source).render(            messages=[{"role": "user", "content": "Hi."}]        )        print(f"{name}: rendered {output!r}")    except Exception as error:        message = f"{name}: {type(error).__name__}: {error}"        print(textwrap.fill(message, 76, subsequent_indent="    "))

Run it before you change the environment:

bash
python show_helpers.py

Expected output (before the change).

text
GUARD: UndefinedError: 'raise_exception' is undefinedDATED: UndefinedError: 'strftime_now' is undefined

Now add the two functions. In templatestudio/jinja_env.py, add from datetime import datetime and from typing import NoReturn below import json, and from jinja2.exceptions import TemplateError above the sandbox import, so the imports read as below. The comment line at the top of each partial block, such as # templatestudio/jinja_env.py (imports at the top), only says where the code goes, and you can leave it out of the file:

python
# templatestudio/jinja_env.py (imports at the top)from __future__ import annotationsimport jsonfrom datetime import datetimefrom typing import NoReturnfrom jinja2.exceptions import TemplateErrorfrom jinja2.sandbox import ImmutableSandboxedEnvironment

Add the two functions between tojson and build_template_env:

python
# templatestudio/jinja_env.py (add above build_template_env)def raise_exception(message: str) -> NoReturn:    """Let a template reject its input with a message of its own."""    raise TemplateError(message)def strftime_now(format_str: str) -> str:    """Today's date or time, for templates that stamp it into a prompt."""    return datetime.now().strftime(format_str)

Then register both in build_template_env, which now reads:

python
# templatestudio/jinja_env.py (replace build_template_env)def build_template_env() -> ImmutableSandboxedEnvironment:    """A Jinja environment configured like the one Transformers 5.22.1 uses."""    env = ImmutableSandboxedEnvironment(        trim_blocks=True,        lstrip_blocks=True,    )    env.filters["tojson"] = tojson    env.globals["raise_exception"] = raise_exception    env.globals["strftime_now"] = strftime_now    return env

Run it.

bash
python show_helpers.py

Expected output. Your date will be the day you run it:

text
GUARD: TemplateError: the first message must be a system messageDATED: rendered 'Today is 2026-11-09.'

What just happened. GUARD now fails the way it fails in production, with TemplateError and the message the template author wrote, and DATED renders. A name in env.globals is visible to every template the environment compiles, the same way range and namespace are built in. Jinja does not check for a missing global when it compiles the template. The error comes only when the line that calls it runs, which is why a template can work for months and then fail on the first conversation that reaches its guard.

Step 5: Fix "Encountered unknown tag 'generation'" with Jinja extensions

Goal. Install the two extensions Transformers installs, so templates that use {% generation %} or {% break %} compile.

Why this step. Steps 2 to 4 changed what a template writes, and this one decides whether it compiles at all. Stock Jinja has no {% break %} or {% continue %}. They come from the jinja2.ext.loopcontrols extension. It also has no {% generation %}, the tag that marks which characters the assistant wrote. Transformers claims it with an extension called AssistantTracker, which records those positions when you ask apply_chat_template for an assistant-token mask, a list of 0s and 1s that marks which tokens the assistant wrote. A string render passes the tag's contents through unchanged, so here you need only a small extension that claims the tag. Without the extensions the template fails before any variable is read.

d2
direction: down
src: "template source\n... {% generation %} ... {% break %} ..." {
  style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
parse: "env.from_string() parses the source\neach {% tag %} must belong to Jinja or an extension" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
fail: "TemplateSyntaxError:\nEncountered unknown tag 'generation'." {
  style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}
}
ok: "compiled template\nGenerationExtension · loopcontrols" {
  style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
}
src -> parse
parse -> fail: "no extension claims the tag"
parse -> ok: "extensions installed"

This is a parse-time failure, so it is louder than the problems in steps 1 to 4: from_string raises an exception instead of returning a slightly different string.

Create show_extensions.py:

python
# show_extensions.pyimport textwrapfrom templatestudio.jinja_env import build_template_envMARKED = (    "{% for message in messages %}"    "{% generation %}{{ message['content'] }}{% endgeneration %}"    "{% endfor %}")FIRST_ONLY = (    "{% for message in messages %}"    "{{ message['content'] }}{% break %}"    "{% endfor %}")MESSAGES = [    {"role": "user", "content": "Hi."},    {"role": "assistant", "content": "Hello."},]env = build_template_env()for name, source in [("MARKED", MARKED), ("FIRST_ONLY", FIRST_ONLY)]:    try:        output = env.from_string(source).render(messages=MESSAGES)        print(f"{name}: rendered {output!r}")    except Exception as error:        message = f"{name}: {type(error).__name__}: {error}"        print(textwrap.fill(message, 76, subsequent_indent="    "))

Run it before you change the environment:

bash
python show_extensions.py

Expected output (before the change).

text
MARKED: TemplateSyntaxError: Encountered unknown tag 'generation'. Jinja was    looking for the following tags: 'endfor' or 'else'. The innermost block    that needs to be closed is 'for'.FIRST_ONLY: TemplateSyntaxError: Encountered unknown tag 'break'. Jinja was    looking for the following tags: 'endfor' or 'else'. The innermost block    that needs to be closed is 'for'.

Now add the extensions. Extend the imports in templatestudio/jinja_env.py with three more lines from Jinja:

python
# templatestudio/jinja_env.py (imports at the top)from __future__ import annotationsimport jsonfrom datetime import datetimefrom typing import NoReturnfrom jinja2 import nodesfrom jinja2.exceptions import TemplateErrorfrom jinja2.ext import Extensionfrom jinja2.parser import Parserfrom jinja2.sandbox import ImmutableSandboxedEnvironment

Add the extension class between strftime_now and build_template_env:

python
# templatestudio/jinja_env.py (add above build_template_env)class GenerationExtension(Extension):    """Claim the {% generation %} tag and pass its body through unchanged."""    tags = {"generation"}    def parse(self, parser: Parser) -> nodes.Node:        lineno = next(parser.stream).lineno        body = parser.parse_statements(("name:endgeneration",), drop_needle=True)        return nodes.Scope(body, lineno=lineno)

Then pass both extensions in build_template_env:

python
# templatestudio/jinja_env.py (replace build_template_env)def build_template_env() -> ImmutableSandboxedEnvironment:    """A Jinja environment configured like the one Transformers 5.22.1 uses."""    env = ImmutableSandboxedEnvironment(        trim_blocks=True,        lstrip_blocks=True,        extensions=["jinja2.ext.loopcontrols", GenerationExtension],    )    env.filters["tojson"] = tojson    env.globals["raise_exception"] = raise_exception    env.globals["strftime_now"] = strftime_now    return env

Run it.

bash
python show_extensions.py

Expected output.

text
MARKED: rendered 'Hi.Hello.'FIRST_ONLY: rendered 'Hi.'

What just happened. Both templates compile. parse runs when Jinja reaches {% generation %}: it consumes the tag and calls parse_statements, which collects everything up to the token named endgeneration. That is what the test string "name:endgeneration" means, and drop_needle=True consumes the closing tag so it is not part of the body. parse then returns the body wrapped in a Scope node, so it renders as if the tags were not there. Transformers' AssistantTracker returns a different node, a call block that also records the positions, but for a string render the output is the same. FIRST_ONLY stopped after the first message because {% break %} now exists. With both extensions in place the environment is complete, and the next step uses it to find a whitespace bug.

Step 6: Debug a missing whitespace dash in a Jinja chat template

Goal. Delete one - from Qwen3's template and measure the change in the string and in the token IDs.

Why this step. Step 2's options cover block tags, {% ... %}. They do not cover expression tags, {{ ... }}, which print values. lstrip_blocks never removes the spaces before an expression tag, so a template that indents an expression line depends on the - modifier instead. A - just inside a delimiter, as in {{-, {%-, -}} or -%}, removes all whitespace on that side, newlines included. That is why every tag in Qwen3's template starts with {{- or {%-. Because your environment now renders faithfully, you can delete one dash and trust what you see.

d2
grid-rows: 1
grid-gap: 40
kept: "with the dash" {
  label.near: top-center
  grid-rows: 2
  grid-gap: 16
  src: "{%- endif %} + newline\n········{{- '<|im_end|>\\n' }}" {style: {fill: "#95A5A6"; stroke: "#6F7C7D"; font-color: "#2C2C2A"}}
  out: "'...Thursday.<|im_end|>'\n{{- removes ········" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}
dropped: "without the dash" {
  label.near: top-center
  grid-rows: 2
  grid-gap: 16
  src: "{%- endif %} + newline\n········{{ '<|im_end|>\\n' }}" {style: {fill: "#95A5A6"; stroke: "#6F7C7D"; font-color: "#2C2C2A"}}
  out: "'...Thursday.········<|im_end|>'\nlstrip_blocks skips {{ }}" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}

The grey cells are the two lines of Qwen3's template that close an assistant turn. trim_blocks removes the newline after {%- endif %} either way. Only the dash removes the eight spaces of indentation before the expression tag.

Create show_dash.py. Type the \\n exactly as shown: inside a Python string, \\n is a backslash followed by n, which is what the template source contains:

python
# show_dash.pyimport sysfrom naive import CONVERSATIONfrom templatestudio.jinja_env import build_template_envfrom templatestudio.render import first_divergence, load_tokenizer, visible_whitespacesys.stdout.reconfigure(encoding="utf-8")tokenizer = load_tokenizer("Qwen/Qwen3-0.6B")original = tokenizer.chat_template# Delete the dash from the tag that closes an assistant turn.broken = original.replace("{{- '<|im_end|>\\n' }}", "{{ '<|im_end|>\\n' }}", 1)print("dashes removed:", original.count("{{-") - broken.count("{{-"))env = build_template_env()good = env.from_string(original).render(    messages=CONVERSATION, add_generation_prompt=True)bad = env.from_string(broken).render(    messages=CONVERSATION, add_generation_prompt=True)at = first_divergence(good, bad)print("characters:", len(good), "->", len(bad), "| first divergence at", at)print("--- the assistant turn, broken template:")start = bad.rfind("<|im_start|>", 0, at)end = bad.find("<|im_end|>", at) + len("<|im_end|>")print(visible_whitespace(bad[start:end]))good_ids = tokenizer.encode(good, add_special_tokens=False)bad_ids = tokenizer.encode(bad, add_special_tokens=False)i = first_divergence(good_ids, bad_ids)print("tokens:", len(good_ids), "->", len(bad_ids), "| first divergence at", i)print("correct:", good_ids[i], repr(tokenizer.decode([good_ids[i]])))print("broken: ", bad_ids[i], repr(tokenizer.decode([bad_ids[i]])))

The 1 at the end of replace changes only the first match, which is the assistant branch. tokenizer.encode(..., add_special_tokens=False) is how apply_chat_template itself turns the rendered string into IDs.

Run it.

bash
python show_dash.py

Expected output.

text
dashes removed: 1characters: 292 -> 300 | first divergence at 197--- the assistant turn, broken template:<|im_start|>assistant\nIt·shipped·Monday·and·arrives·Thursday.········<|im_end|>tokens: 59 -> 60 | first divergence at 42correct: 151645 '<|im_end|>'broken:  260 '        '

What just happened. Removing one character from the template added eight characters to the render and one token to the sequence. Token 42 should be <|im_end|>, ID 151645, the token that ends the assistant's turn. With the bug it is ID 260, a run of eight spaces, and <|im_end|> follows one position later. Nothing raised an error, and the text still reads correctly.

The model, though, now sees eight spaces before the end of every assistant turn in its history, a sequence Qwen3's own template never produces. This is the silent format penalty from Part 1: no error, readable text, and a different token sequence. Step 1's environment would have hidden it. In a plain jinja2.Environment() every turn already had stray whitespace, so one more run of spaces would not have stood out.

Step 7: Validate and render a chat template in one function

Goal. Add a first version of render_template_string, one function that validates a conversation and renders a template string in the faithful environment.

Why this step. Every script so far called build_template_env().from_string(...).render(...) itself. When every caller repeats those calls, every caller can also forget Part 2's validation. One function gives all of them the same path: check the input contract, compile in the faithful environment, then render.

Add one import to templatestudio/jinja_env.py, below the Jinja imports and separated from them by a blank line:

python
# templatestudio/jinja_env.py (add below the jinja2 imports)from templatestudio.messages import Conversation, assert_valid

Then add a first version of the function at the end of the file. It validates and renders, but does not yet pass tools or documents:

python
# templatestudio/jinja_env.py (add at the end)def render_template_string(    template_source: str,    conversation: Conversation,    *,    add_generation_prompt: bool = False,    **kwargs: object,) -> str:    """Render a chat template string the way apply_chat_template does."""    assert_valid(conversation)    template = build_template_env().from_string(template_source)    return template.render(        messages=conversation,        add_generation_prompt=add_generation_prompt,        **kwargs,    )

Two details copy Transformers on purpose. First, add_generation_prompt defaults to False, as it does in apply_chat_template. Part 1's render defaults to True, so pass the value explicitly whenever you compare the two. Second, assert_valid from Part 2 runs first, so a malformed conversation is refused before the template sees it.

Create show_tools_defined.py, the checkpoint for this step and the next. TOOLS_CHECK is a small template that announces tools whenever a variable called tools exists. apply_chat_template accepts any template string through its chat_template argument, so you can compare all three renders:

python
# show_tools_defined.pyfrom naive import CONVERSATIONfrom templatestudio.jinja_env import build_template_env, render_template_stringfrom templatestudio.render import load_tokenizer# Announces tools whenever a variable called tools exists, even if it is None.TOOLS_CHECK = (    "{% if tools is defined %}tools: {{ tools | tojson }}"    "{% else %}no tools variable{% endif %}")tokenizer = load_tokenizer("Qwen/Qwen3-0.6B")production = tokenizer.apply_chat_template(    CONVERSATION, chat_template=TOOLS_CHECK, tokenize=False)bare = build_template_env().from_string(TOOLS_CHECK).render(messages=CONVERSATION)faithful = render_template_string(TOOLS_CHECK, CONVERSATION)print(f"apply_chat_template:    {production!r}")print(f"bare render:            {bare!r}")print(f"render_template_string: {faithful!r}")

Run it.

bash
python show_tools_defined.py

Expected output.

text
apply_chat_template:    'tools: null'bare render:            'no tools variable'render_template_string: 'no tools variable'

What just happened. render_template_string agrees with the bare render and not with apply_chat_template, because neither of them passes tools. The next step closes that gap. What the function already gives you is one place where validation cannot be skipped, and one place to change when Transformers changes its render call. Steps 8 and 9 change only this function, and step 10 changes how you call it.

Step 8: Pass tools=None and documents=None like apply_chat_template

Goal. Give render_template_string the tools and documents variables that Transformers always passes.

Why this step. The environment is now faithful, but the variables are not yet. Until now you passed only messages and add_generation_prompt. Transformers 5.22.1 always renders with tools= and documents=, and sets each to None when you give it none. So inside apply_chat_template the test tools is defined is always true, even when there are no tools. A template that tests is defined therefore takes a different branch in a bare call than in production.

d2
grid-rows: 1
grid-gap: 40
bare: "bare render()" {
  label.near: top-center
  grid-rows: 2
  grid-gap: 14
  m: "messages, add_generation_prompt" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  t: "tools, documents: undefined\ntools is defined -> false" {style: {fill: "#E74C3C"; stroke: "#B03A2E"; font-color: "#FFFFFF"}}
}
hf: "apply_chat_template" {
  label.near: top-center
  grid-rows: 2
  grid-gap: 14
  m: "messages, add_generation_prompt" {style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}}
  t: "tools=None, documents=None\ntools is defined -> true" {style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}}
}

The top row is the same in both calls. The bottom row is the difference this step closes: the same test gives a different answer, so the same template takes a different branch.

Give render_template_string the two parameters, with the defaults Transformers uses, and pass them to the template. The function now reads:

python
# templatestudio/jinja_env.py (replace render_template_string)def render_template_string(    template_source: str,    conversation: Conversation,    *,    add_generation_prompt: bool = False,    tools: list[dict] | None = None,    documents: list[dict] | None = None,    **kwargs: object,) -> str:    """Render a chat template string the way apply_chat_template does."""    assert_valid(conversation)    template = build_template_env().from_string(template_source)    return template.render(        messages=conversation,        tools=tools,        documents=documents,        add_generation_prompt=add_generation_prompt,        **kwargs,    )

Run it.

bash
python show_tools_defined.py

Expected output.

text
apply_chat_template:    'tools: null'bare render:            'no tools variable'render_template_string: 'tools: null'

What just happened. No tools were passed anywhere, and production still took the tools is defined branch and wrote null, the JSON for None. The bare render took the other branch. A template written this way would announce a tools section, tools: null, to the model on every request, and a bare local render would never show it to you. Careful templates test {% if tools %} for this reason, because it is false for both None and an undefined name. render_template_string now reproduces what production did, so use it from here on instead of env.from_string(...).render(...).

Step 9: Fix an empty think block by copying reasoning fields like apply_chat_template

Goal. Add alias_reasoning, which copies a message's reasoning to the names chat templates read, and render a reply whose reasoning sits under "reasoning".

Why this step. Clients store a model's reasoning under different keys: reasoning_content, thinking or reasoning. Each template reads only one of them. Qwen3's template reads reasoning_content, and gpt-oss templates read thinking, as a comment in Transformers' own source notes. So before it renders, Transformers 5.22.1 takes the first of the three keys that is set, in that order. It writes that text under reasoning_content, and also under thinking if the message has no thinking of its own. Until your function does the same, a reply stored under reasoning renders with its reasoning in production and without it locally.

d2
direction: down
msg: "your message\n{'content': 'No.', 'reasoning': 'It arrives Thursday.'}" {
  style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}
}
pick: "first key that is set\nreasoning_content -> thinking -> reasoning" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
out: "message the template sees\nthinking · content · reasoning · reasoning_content" {
  style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
}
tpl: "Qwen3 reads reasoning_content\ngpt-oss reads thinking" {
  style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
msg -> pick: "1. look up"
pick -> out: "2. copy to both names"
out -> tpl: "3. render"

The purple box is the order of preference, and the green box is the copy the template receives. Your original message is not changed.

Create reasoning_turns.py:

python
# reasoning_turns.py# An assistant reply that carries its reasoning under the key "reasoning".REASONED = [    {"role": "user", "content": "Is order #4471 late?"},    {"role": "assistant", "content": "No.", "reasoning": "It arrives Thursday."},]

Create show_reasoning.py. It renders REASONED with Qwen3's template both ways and prints the assistant turn from your render:

python
# show_reasoning.pyfrom reasoning_turns import REASONEDfrom templatestudio.jinja_env import render_template_stringfrom templatestudio.render import load_tokenizertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")production = tokenizer.apply_chat_template(REASONED, tokenize=False)local = render_template_string(tokenizer.chat_template, REASONED)print("same string as apply_chat_template:", local == production)print("--- the assistant turn, local render:")print(local[local.rindex("<|im_start|>") :], end="")

Run it before you change the function:

bash
python show_reasoning.py

Expected output (before the change).

text
same string as apply_chat_template: False--- the assistant turn, local render:<|im_start|>assistant<think></think>No.<|im_end|>

The <think> block is empty, because the template found no reasoning_content. Now add the alias. In templatestudio/jinja_env.py, add this block between build_template_env and render_template_string:

python
# templatestudio/jinja_env.py (add above render_template_string)REASONING_FIELDS = ("reasoning_content", "thinking", "reasoning")def alias_reasoning(conversation: Conversation) -> list[dict[str, object]]:    """Copy a message's reasoning under the names templates read."""    aliased: list[dict[str, object]] = []    for message in conversation:        reasoning = next(            (message[k] for k in REASONING_FIELDS if message.get(k) is not None),            None,        )        if reasoning is None:            aliased.append(dict(message))        else:            aliased.append(                {"thinking": reasoning, **message, "reasoning_content": reasoning}            )    return aliased

Then change one line inside the template.render(...) call in render_template_string. Replace messages=conversation, with the line below, so the template receives the aliased copy:

python
# templatestudio/jinja_env.py (in render_template_string, replace messages=...)        messages=alias_reasoning(conversation),

Run it.

bash
python show_reasoning.py

Expected output.

text
same string as apply_chat_template: True--- the assistant turn, local render:<|im_start|>assistant<think>It arrives Thursday.</think>No.<|im_end|>

What just happened. The reasoning reached the template, and the whole string now matches production. The order of the keys in the new dict copies Transformers 5.22.1 exactly. thinking comes first, so a message that already has its own thinking keeps it when **message is spread over it. reasoning_content comes last, so it always holds the chosen text. alias_reasoning builds a new dict for every message, so the conversation you passed in, which Part 2 validated, is never changed. It does read keys that Part 2's Message type does not declare, so a strict type checker flags message[k]. The code runs correctly, and Part 6 covers reasoning messages in depth. All edits to templatestudio/jinja_env.py are now done, and the file should match the listing in the Complete code section exactly.

Step 10: Pass the tokenizer's special tokens to the template

Goal. Give templates the special-token variables, such as eos_token, that apply_chat_template adds, and keep your own keyword arguments able to override them.

Why this step. Before it renders, apply_chat_template adds the tokenizer's special_tokens_map, its named special tokens such as eos_token and bos_token, to the template's variables. Your own keyword arguments win when a name is in both. Qwen3's template reads none of these tokens, but many templates write eos_token or bos_token directly. Gemma 3's template, for example, starts with {{ bos_token }}. Without the special tokens, that line renders as an empty string, and every prompt loses the token it should start with.

render_template_string takes no tokenizer, so that jinja_env.py never imports Transformers and can run in a test or CI job that has no tokenizer at all. The caller passes the tokens in instead, and the safe way to combine them with your options is to merge the two dicts first, with your options last.

d2
direction: down
tok: "tokenizer.special_tokens_map\neos_token · pad_token" {
  style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
mine: "your options\nfor example eos_token='[END]'" {
  style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}
}
merge: "{**special_tokens, **options}\nlater keys win" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
tvars: "template variables" {
  style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
tok -> merge: "1. first"
mine -> merge: "2. second"
merge -> tvars: "3. one dict"

Writing **special_tokens, **options as two separate arguments looks equivalent, but Python raises TypeError when both dicts hold the same name. The merged dict reproduces the override rule instead.

Create show_special_tokens.py. EOS_CHECK is a small template that ends every message with eos_token. The script renders it without the tokens and with the merged dict, first with no options and then with your own eos_token:

python
# show_special_tokens.pyfrom naive import CONVERSATIONfrom templatestudio.jinja_env import render_template_stringfrom templatestudio.render import load_tokenizer# Ends every message with the tokenizer's end-of-sequence token.EOS_CHECK = "{% for m in messages %}{{ m['content'] }}{{ eos_token }}\n{% endfor %}"tokenizer = load_tokenizer("Qwen/Qwen3-0.6B")special_tokens = tokenizer.special_tokens_mapprint("special tokens:", special_tokens)for options in [{}, {"eos_token": "[END]"}]:    production = tokenizer.apply_chat_template(        CONVERSATION, chat_template=EOS_CHECK, tokenize=False, **options    )    without = render_template_string(EOS_CHECK, CONVERSATION, **options)    merged = render_template_string(        EOS_CHECK, CONVERSATION, **{**special_tokens, **options}    )    print(f"options {options}:")    print("  without special tokens:", without == production)    print("  with special tokens:   ", merged == production)    print("  first line:", merged.splitlines()[0])

Run it.

bash
python show_special_tokens.py

Expected output.

text
special tokens: {'eos_token': '<|im_end|>', 'pad_token': '<|endoftext|>'}options {}:  without special tokens: False  with special tokens:    True  first line: You are a terse support agent. Answer in one sentence.<|im_end|>options {'eos_token': '[END]'}:  without special tokens: True  with special tokens:    True  first line: You are a terse support agent. Answer in one sentence.[END]

What just happened. With no options, the render without the tokens fails, because an undefined eos_token prints as an empty string, and the merged render matches. With your own eos_token, both match, and the first line ends in [END]: your value replaced the tokenizer's, as it does in apply_chat_template. Qwen3-0.6B's tokenizer defines only two named special tokens, so those are the only two passed in.

Step 11: Prove render_template_string matches apply_chat_template byte for byte

Goal. Compare render_template_string with apply_chat_template across ten cases, and confirm that a malformed conversation is still refused.

Why this step. Steps 8 to 10 each checked one variable on its own example. The claim of this tutorial is broader: the module matches production for the shapes of input your code sends. So test those shapes together, along with the features each earlier step added. Qwen3's template runs with and without the generation prompt, with tools, on a tool round trip from Part 2, with the enable_thinking option, and on a reasoning field. Four small templates cover the rest: step 1's readable template, one that uses step 4's helpers and step 5's tags, one that tests tools is defined, and one that reads a special token. Three extra columns show where each earlier piece matters. vanilla is plain jinja2.Environment(). bare is your step 5 environment, rendered directly, without steps 7 to 10. tokens encodes your string and compares its token IDs with the ones apply_chat_template returns.

Create prove_faithful.py. TOOLS_CHECK and EOS_CHECK are the same templates as in steps 7 and 10. A plain environment cannot even compile HELPERS, so matches turns a render that raises a Jinja error into error instead of stopping the script:

python
# prove_faithful.pyfrom pathlib import Pathimport jinja2from drift import DRIFTEDfrom naive import CONVERSATIONfrom order_tools import TOOLSfrom reasoning_turns import REASONEDfrom templatestudio.jinja_env import build_template_env, render_template_stringfrom templatestudio.messages import InvalidConversationfrom templatestudio.render import load_tokenizerfrom tool_turns import ROUND_TRIPtokenizer = load_tokenizer("Qwen/Qwen3-0.6B")QWEN3 = tokenizer.chat_templateREADABLE = Path("readable_chatml.jinja").read_text(encoding="utf-8")# The same templates as steps 7 and 10.TOOLS_CHECK = (    "{% if tools is defined %}tools: {{ tools | tojson }}"    "{% else %}no tools variable{% endif %}")EOS_CHECK = "{% for m in messages %}{{ m['content'] }}{{ eos_token }}\n{% endfor %}"# Uses both helpers from step 4 and both extensions from step 5.HELPERS = (    "{% if messages[0]['role'] != 'system' %}{{ raise_exception('no system') }}"    "{% endif %}{% for m in messages %}{% generation %}{{ m['content'] }}"    "{% endgeneration %}{% break %}{% endfor %} {{ strftime_now('%Y') }}")PROMPT = {"add_generation_prompt": True}CASES = [    ("support chat", QWEN3, CONVERSATION, PROMPT),    ("support chat, no prompt", QWEN3, CONVERSATION, {}),    ("with a tool schema", QWEN3, CONVERSATION, {**PROMPT, "tools": TOOLS}),    ("tool round trip", QWEN3, ROUND_TRIP, PROMPT),    ("thinking off", QWEN3, CONVERSATION, {**PROMPT, "enable_thinking": False}),    ("reasoning field", QWEN3, REASONED, {}),    ("readable ChatML", READABLE, CONVERSATION, PROMPT),    ("helpers and extensions", HELPERS, CONVERSATION, {}),    ("tests tools is defined", TOOLS_CHECK, CONVERSATION, {}),    ("reads eos_token", EOS_CHECK, CONVERSATION, {}),]def matches(render, expected):    """True or False if the render ran, "error" if it raised."""    try:        return str(render() == expected)    except jinja2.TemplateError:        return "error"special_tokens = tokenizer.special_tokens_mapprint(f"{'case':24} {'vanilla':8} {'bare':6} {'faithful':9} tokens")for name, source, conversation, options in CASES:    production = tokenizer.apply_chat_template(        conversation, chat_template=source, tokenize=False, **options    )    production_ids = tokenizer.apply_chat_template(        conversation, chat_template=source, **options    )["input_ids"]    vanilla = matches(        lambda: jinja2.Environment()        .from_string(source)        .render(messages=conversation, **options),        production,    )    bare = matches(        lambda: build_template_env()        .from_string(source)        .render(messages=conversation, **options),        production,    )    faithful = render_template_string(        source, conversation, **{**special_tokens, **options}    )    ids = tokenizer.encode(faithful, add_special_tokens=False)    print(        f"{name:24} {vanilla:8} {bare:6}"        f" {str(faithful == production):9} {ids == production_ids}"    )try:    render_template_string(QWEN3, DRIFTED)except InvalidConversation as error:    print("drifted conversation refused:", error)

enable_thinking is a keyword argument Qwen3's template reads to skip its reasoning block. Any keyword you pass to apply_chat_template that it does not recognize becomes a template variable, and render_template_string passes it through **kwargs the same way.

Run it.

bash
python prove_faithful.py

Expected output.

text
case                     vanilla  bare   faithful  tokenssupport chat             True     True   True      Truesupport chat, no prompt  True     True   True      Truewith a tool schema       False    True   True      Truetool round trip          True     True   True      Truethinking off             True     True   True      Truereasoning field          False    False  True      Truereadable ChatML          False    True   True      Truehelpers and extensions   error    True   True      Truetests tools is defined   False    False  True      Truereads eos_token          False    False  True      Truedrifted conversation refused: message 3: unknown role 'User'

What just happened. All ten renders through render_template_string are byte-identical to production, and so are their token IDs. Each False and error traces back to a step. In the vanilla column, the tool schema fails on step 3's tojson, the readable template on step 2's whitespace options, and the helpers template does not compile without step 5's extensions, and would fail at render time without step 4's globals. In both vanilla and bare, the reasoning field, tests tools is defined and reads eos_token rows fail on the variables from steps 8 to 10, so even the right environment produces the wrong string without them. Qwen3's template passes the other vanilla rows only because it puts a - on every tag. The last line is Part 2's input contract, still enforced in front of the new renderer.

This proves the module for Qwen3-0.6B's template and four small templates of your own, on Transformers 5.22.1, and for no other model. When you point render_template_string at another model's template, add that template to CASES and run this script first.

Fix common Jinja chat template errors: TemplateSyntaxError, UndefinedError and SecurityError

Every row below was reproduced on the versions this tutorial pins, except the two marked as quoted, which name their sources.

Error or symptomRoot causeFix
jinja2.exceptions.TemplateSyntaxError: Encountered unknown tag 'generation'. When the tag is inside a loop, Jinja adds Jinja was looking for the following tags: 'endfor' or 'else'. The innermost block that needs to be closed is 'for'.The environment has no extension that claims {% generation %}Pass GenerationExtension in extensions= (step 5), or render through render_template_string
jinja2.exceptions.TemplateSyntaxError: Encountered unknown tag 'break'.{% break %} and {% continue %} without jinja2.ext.loopcontrolsAdd "jinja2.ext.loopcontrols" to extensions=
jinja2.exceptions.UndefinedError: 'raise_exception' is undefined (or 'strftime_now' is undefined)The template calls a function Transformers injects, and your environment does notRegister both in env.globals (step 4)
TypeError: do_tojson() got an unexpected keyword argument 'ensure_ascii'A template calls tojson(ensure_ascii=False) in an environment that still has Jinja's built-in filter, which accepts only indentOverride the filter with Transformers' signature (step 3)
A tool schema renders with sorted keys and \u0027 or \u003c in place of ' and <Jinja's built-in tojson, which sorts keys and escapes HTML charactersOverride tojson (step 3)
Stray \n and indentation before each turn in a local render, as in step 1trim_blocks and lstrip_blocks are off, which is Jinja's defaultUse build_template_env, never a bare jinja2.Environment()
jinja2.exceptions.SecurityError: access to attribute 'append' of 'list' object is unsafe.A template tried to change messages or another input. The immutable sandbox blocks it, in production as wellBuild a new value with {% set %} or namespace() instead of changing the input
jinja2.exceptions.UndefinedError: 'tools' is undefinedA template indexes tools[0] in a bare render that never passed tools. Through apply_chat_template this cannot happen, because tools is always passedRender with render_template_string, and guard the template with {% if tools %}
jinja2.exceptions.UndefinedError: None has no element 0A template reads tools[0].function when tools is None, which is what production passes when you send no tools. {{ tools[0] }} alone prints nothing, without an errorTest {% if tools %} before indexing
TypeError: 'NoneType' object is not iterable{% for tool in tools %} with tools=None. A loop over an undefined name runs zero times, so a bare render hides this crashWrap the loop in {% if tools %}
jinja2.exceptions.TemplateSyntaxError: unexpected end of template, expected 'end of statement block'.A tag is not closed, for example {% endfor without its %}Close the tag. The error names what Jinja expected next
templatestudio.messages.InvalidConversation: message 3: unknown role 'User' from render_template_stringThe input contract from Part 2 runs before the renderFix the role where it is written, as Part 2 describes
show_dash.py prints dashes removed: 0 and characters: 292 -> 292 | first divergence at None, among other lines, then TypeError: list indices must be integers or slices, not NoneTypeThe search string was typed with \n instead of \\n. Python reads \n as a newline, which the template source does not contain at that point, so replace found nothingType \\n exactly as in step 6
TypeError: templatestudio.jinja_env.render_template_string() got multiple values for keyword argument 'eos_token'The special tokens and your options were passed as two separate ** arguments, and both contain eos_tokenMerge them first, **{**special_tokens, **options}, so your value wins as it does in apply_chat_template (step 10)
An empty <think> block, <think>\n\n</think>, in an assistant turn whose message stores its reasoning under reasoningThe template reads reasoning_content, and nothing copied the reasoning to that name (step 9's before-the-change output)Render through render_template_string with alias_reasoning in place, or store the reasoning under the name the template reads
Quoted from Part 2: ImportError: cannot import name 'NotRequired' from 'typing'Python 3.10 or older. Part 2's messages.py imports typing.NotRequired, which arrived in Python 3.11. Part 2 reproduced this on Python 3.10.11Create the virtual environment with Python 3.11 or later; this tutorial uses 3.12
Quoted: apply_chat_template requires jinja2>=3.1.6 to be installed. Your version is 3.1.5. You can upgrade it with `pip install -U jinja2`. (with your own version number)On Transformers 5.22.2 or later only, a Jinja2 release without both sandbox fixes. 5.22.1 accepts 3.1.0 and later. Source: the v5.22.2 chat_template_utils.pypip install "jinja2==3.1.6"

Render a chat template without Transformers: the module layout

The project now has three modules of your own in templatestudio/. render.py still calls apply_chat_template, and the new jinja_env.py renders template strings itself, without Transformers.

d2
direction: down
scripts: "scripts\nshow_vanilla · show_faithful\nshow_tojson · show_helpers\nshow_extensions · show_dash\nshow_tools_defined · show_reasoning\nshow_special_tokens · prove_faithful" {
  style: {fill: "#98D8C8"; stroke: "#5FA898"; font-color: "#2C2C2A"}
}
data: "data files\nreadable_chatml.jinja\norder_tools.py: TOOLS\nreasoning_turns.py: REASONED\nnaive.py · drift.py · tool_turns.py" {
  style: {fill: "#FFA07A"; stroke: "#D9785A"; font-color: "#2C2C2A"}
}
jinja: "templatestudio/jinja_env.py\nbuild_template_env · tojson\nraise_exception · strftime_now\nGenerationExtension\nalias_reasoning\nrender_template_string" {
  style: {fill: "#7B68EE"; stroke: "#5A4BC4"; font-color: "#FFFFFF"}
}
messages: "templatestudio/messages.py (Part 2)\nConversation · Message · assert_valid" {
  style: {fill: "#FFD93D"; stroke: "#C9A800"; font-color: "#2C2C2A"}
}
render: "templatestudio/render.py\n(Part 2, unchanged)\nrender · safe_render\nvisible_whitespace\nfirst_divergence" {
  style: {fill: "#4A90E2"; stroke: "#2C6FB0"; font-color: "#FFFFFF"}
}
hub: "Hub: Qwen/Qwen3-0.6B\ntokenizer + chat_template" {
  style: {fill: "#6BCF7F"; stroke: "#3F9E52"; font-color: "#2C2C2A"}
}
scripts -> data: "templates, conversations"
scripts -> jinja: "render_template_string"
scripts -> render: "to compare"
jinja -> messages: "assert_valid"
render -> messages: "assert_valid"
render -> hub: "apply_chat_template"

Two modules now depend on messages.py, and by design neither depends on the other. jinja_env.py imports only Jinja and your message types, so you can render a template in a test or a CI job that never installs Transformers or downloads a tokenizer. The scripts are the only place where the two renderers meet, because comparing them is how you know the faithful one is still faithful. When you upgrade Transformers, run prove_faithful.py again before you trust jinja_env.py on the new version.

Complete code: a faithful chat template renderer in Python

These are the final versions of every file this part creates, without the # filename comment lines. Files from Parts 1 and 2 are unchanged and not repeated here.

text
chat-templates-part1/├── naive.py               (Part 1, unchanged)├── drift.py               (Part 2, unchanged)├── tool_turns.py          (Part 2, unchanged)├── readable_chatml.jinja├── order_tools.py├── reasoning_turns.py├── templatestudio/│   ├── __init__.py        (empty)│   ├── jinja_env.py│   ├── messages.py        (Part 2, unchanged)│   └── render.py          (Part 2, unchanged)├── show_vanilla.py├── show_faithful.py├── show_tojson.py├── show_helpers.py├── show_extensions.py├── show_dash.py├── show_tools_defined.py├── show_reasoning.py├── show_special_tokens.py├── prove_faithful.py└── .venv/

If you completed Parts 1 and 2, their scripts also sit in the project root. None of this part's scripts need them.

templatestudio/jinja_env.py:

python
from __future__ import annotationsimport jsonfrom datetime import datetimefrom typing import NoReturnfrom jinja2 import nodesfrom jinja2.exceptions import TemplateErrorfrom jinja2.ext import Extensionfrom jinja2.parser import Parserfrom jinja2.sandbox import ImmutableSandboxedEnvironmentfrom templatestudio.messages import Conversation, assert_validdef tojson(    x: object,    ensure_ascii: bool = False,    indent: int | None = None,    separators: tuple[str, str] | None = None,    sort_keys: bool = False,) -> str:    """Transformers' JSON filter: key order kept, nothing HTML-escaped."""    return json.dumps(        x,        ensure_ascii=ensure_ascii,        indent=indent,        separators=separators,        sort_keys=sort_keys,    )def raise_exception(message: str) -> NoReturn:    """Let a template reject its input with a message of its own."""    raise TemplateError(message)def strftime_now(format_str: str) -> str:    """Today's date or time, for templates that stamp it into a prompt."""    return datetime.now().strftime(format_str)class GenerationExtension(Extension):    """Claim the {% generation %} tag and pass its body through unchanged."""    tags = {"generation"}    def parse(self, parser: Parser) -> nodes.Node:        lineno = next(parser.stream).lineno        body = parser.parse_statements(("name:endgeneration",), drop_needle=True)        return nodes.Scope(body, lineno=lineno)def build_template_env() -> ImmutableSandboxedEnvironment:    """A Jinja environment configured like the one Transformers 5.22.1 uses."""    env = ImmutableSandboxedEnvironment(        trim_blocks=True,        lstrip_blocks=True,        extensions=["jinja2.ext.loopcontrols", GenerationExtension],    )    env.filters["tojson"] = tojson    env.globals["raise_exception"] = raise_exception    env.globals["strftime_now"] = strftime_now    return envREASONING_FIELDS = ("reasoning_content", "thinking", "reasoning")def alias_reasoning(conversation: Conversation) -> list[dict[str, object]]:    """Copy a message's reasoning under the names templates read."""    aliased: list[dict[str, object]] = []    for message in conversation:        reasoning = next(            (message[k] for k in REASONING_FIELDS if message.get(k) is not None),            None,        )        if reasoning is None:            aliased.append(dict(message))        else:            aliased.append(                {"thinking": reasoning, **message, "reasoning_content": reasoning}            )    return aliaseddef render_template_string(    template_source: str,    conversation: Conversation,    *,    add_generation_prompt: bool = False,    tools: list[dict] | None = None,    documents: list[dict] | None = None,    **kwargs: object,) -> str:    """Render a chat template string the way apply_chat_template does."""    assert_valid(conversation)    template = build_template_env().from_string(template_source)    return template.render(        messages=alias_reasoning(conversation),        tools=tools,        documents=documents,        add_generation_prompt=add_generation_prompt,        **kwargs,    )

readable_chatml.jinja (step 1):

jinja
{% for message in messages %}    {% if message['role'] in ['system', 'user', 'assistant'] %}<|im_start|>{{ message['role'] }}{{ message['content'] }}<|im_end|>    {% endif %}{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant{% endif %}

order_tools.py (step 3):

python
# One tool schema, in the JSON shape apply_chat_template accepts.TOOLS = [    {        "type": "function",        "function": {            "name": "track_order",            "description": "Look up an order's status, e.g. <shipped>.",            "parameters": {                "type": "object",                "properties": {"order_id": {"type": "string"}},                "required": ["order_id"],            },        },    }]

reasoning_turns.py (step 9):

python
# An assistant reply that carries its reasoning under the key "reasoning".REASONED = [    {"role": "user", "content": "Is order #4471 late?"},    {"role": "assistant", "content": "No.", "reasoning": "It arrives Thursday."},]

prove_faithful.py (step 11), the check to run again whenever you upgrade Transformers:

python
from pathlib import Pathimport jinja2from drift import DRIFTEDfrom naive import CONVERSATIONfrom order_tools import TOOLSfrom reasoning_turns import REASONEDfrom templatestudio.jinja_env import build_template_env, render_template_stringfrom templatestudio.messages import InvalidConversationfrom templatestudio.render import load_tokenizerfrom tool_turns import ROUND_TRIPtokenizer = load_tokenizer("Qwen/Qwen3-0.6B")QWEN3 = tokenizer.chat_templateREADABLE = Path("readable_chatml.jinja").read_text(encoding="utf-8")# The same templates as steps 7 and 10.TOOLS_CHECK = (    "{% if tools is defined %}tools: {{ tools | tojson }}"    "{% else %}no tools variable{% endif %}")EOS_CHECK = "{% for m in messages %}{{ m['content'] }}{{ eos_token }}\n{% endfor %}"# Uses both helpers from step 4 and both extensions from step 5.HELPERS = (    "{% if messages[0]['role'] != 'system' %}{{ raise_exception('no system') }}"    "{% endif %}{% for m in messages %}{% generation %}{{ m['content'] }}"    "{% endgeneration %}{% break %}{% endfor %} {{ strftime_now('%Y') }}")PROMPT = {"add_generation_prompt": True}CASES = [    ("support chat", QWEN3, CONVERSATION, PROMPT),    ("support chat, no prompt", QWEN3, CONVERSATION, {}),    ("with a tool schema", QWEN3, CONVERSATION, {**PROMPT, "tools": TOOLS}),    ("tool round trip", QWEN3, ROUND_TRIP, PROMPT),    ("thinking off", QWEN3, CONVERSATION, {**PROMPT, "enable_thinking": False}),    ("reasoning field", QWEN3, REASONED, {}),    ("readable ChatML", READABLE, CONVERSATION, PROMPT),    ("helpers and extensions", HELPERS, CONVERSATION, {}),    ("tests tools is defined", TOOLS_CHECK, CONVERSATION, {}),    ("reads eos_token", EOS_CHECK, CONVERSATION, {}),]def matches(render, expected):    """True or False if the render ran, "error" if it raised."""    try:        return str(render() == expected)    except jinja2.TemplateError:        return "error"special_tokens = tokenizer.special_tokens_mapprint(f"{'case':24} {'vanilla':8} {'bare':6} {'faithful':9} tokens")for name, source, conversation, options in CASES:    production = tokenizer.apply_chat_template(        conversation, chat_template=source, tokenize=False, **options    )    production_ids = tokenizer.apply_chat_template(        conversation, chat_template=source, **options    )["input_ids"]    vanilla = matches(        lambda: jinja2.Environment()        .from_string(source)        .render(messages=conversation, **options),        production,    )    bare = matches(        lambda: build_template_env()        .from_string(source)        .render(messages=conversation, **options),        production,    )    faithful = render_template_string(        source, conversation, **{**special_tokens, **options}    )    ids = tokenizer.encode(faithful, add_special_tokens=False)    print(        f"{name:24} {vanilla:8} {bare:6}"        f" {str(faithful == production):9} {ids == production_ids}"    )try:    render_template_string(QWEN3, DRIFTED)except InvalidConversation as error:    print("drifted conversation refused:", error)

The show_*.py scripts are teaching probes and not part of the module. None of them changes after the step that creates it.

show_vanilla.py (step 1):

python
import sysfrom pathlib import Pathimport jinja2from naive import CONVERSATIONfrom templatestudio.render import (    first_divergence,    load_tokenizer,    render,    visible_whitespace,)sys.stdout.reconfigure(encoding="utf-8")source = Path("readable_chatml.jinja").read_text(encoding="utf-8")local = jinja2.Environment().from_string(source).render(    messages=CONVERSATION, add_generation_prompt=True)production = render(load_tokenizer("Qwen/Qwen3-0.6B"), CONVERSATION)print("local:", len(local), "characters; Qwen3:", len(production))print("first divergence at character", first_divergence(local, production))print("--- first 8 lines, local render:")lines = visible_whitespace(local).splitlines(keepends=True)print("".join(lines[:8]), end="")

show_faithful.py (step 2):

python
from pathlib import Pathfrom naive import CONVERSATIONfrom templatestudio.jinja_env import build_template_envfrom templatestudio.render import first_divergence, load_tokenizer, rendersource = Path("readable_chatml.jinja").read_text(encoding="utf-8")local = build_template_env().from_string(source).render(    messages=CONVERSATION, add_generation_prompt=True)production = render(load_tokenizer("Qwen/Qwen3-0.6B"), CONVERSATION)print("local:", len(local), "characters; Qwen3:", len(production))print("first divergence at character", first_divergence(local, production))

show_tojson.py (step 3):

python
import textwrapfrom naive import CONVERSATIONfrom order_tools import TOOLSfrom templatestudio.jinja_env import build_template_envfrom templatestudio.render import first_divergence, load_tokenizertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")local = build_template_env().from_string(tokenizer.chat_template).render(    messages=CONVERSATION, tools=TOOLS, add_generation_prompt=True)production = tokenizer.apply_chat_template(    CONVERSATION, tools=TOOLS, tokenize=False, add_generation_prompt=True)print("first divergence at character", first_divergence(local, production))for name, text in [("local", local), ("Qwen3", production)]:    schema_line = next(line for line in text.splitlines() if line.startswith("{"))    print(f"--- {name}, the tool schema line:")    print(textwrap.fill(schema_line, 72))

show_helpers.py (step 4):

python
import textwrapfrom templatestudio.jinja_env import build_template_envGUARD = (    "{% if messages[0]['role'] != 'system' %}"    "{{ raise_exception('the first message must be a system message') }}"    "{% endif %}system message present")DATED = "Today is {{ strftime_now('%Y-%m-%d') }}."env = build_template_env()for name, source in [("GUARD", GUARD), ("DATED", DATED)]:    try:        output = env.from_string(source).render(            messages=[{"role": "user", "content": "Hi."}]        )        print(f"{name}: rendered {output!r}")    except Exception as error:        message = f"{name}: {type(error).__name__}: {error}"        print(textwrap.fill(message, 76, subsequent_indent="    "))

show_extensions.py (step 5):

python
import textwrapfrom templatestudio.jinja_env import build_template_envMARKED = (    "{% for message in messages %}"    "{% generation %}{{ message['content'] }}{% endgeneration %}"    "{% endfor %}")FIRST_ONLY = (    "{% for message in messages %}"    "{{ message['content'] }}{% break %}"    "{% endfor %}")MESSAGES = [    {"role": "user", "content": "Hi."},    {"role": "assistant", "content": "Hello."},]env = build_template_env()for name, source in [("MARKED", MARKED), ("FIRST_ONLY", FIRST_ONLY)]:    try:        output = env.from_string(source).render(messages=MESSAGES)        print(f"{name}: rendered {output!r}")    except Exception as error:        message = f"{name}: {type(error).__name__}: {error}"        print(textwrap.fill(message, 76, subsequent_indent="    "))

show_dash.py (step 6):

python
import sysfrom naive import CONVERSATIONfrom templatestudio.jinja_env import build_template_envfrom templatestudio.render import first_divergence, load_tokenizer, visible_whitespacesys.stdout.reconfigure(encoding="utf-8")tokenizer = load_tokenizer("Qwen/Qwen3-0.6B")original = tokenizer.chat_template# Delete the dash from the tag that closes an assistant turn.broken = original.replace("{{- '<|im_end|>\\n' }}", "{{ '<|im_end|>\\n' }}", 1)print("dashes removed:", original.count("{{-") - broken.count("{{-"))env = build_template_env()good = env.from_string(original).render(    messages=CONVERSATION, add_generation_prompt=True)bad = env.from_string(broken).render(    messages=CONVERSATION, add_generation_prompt=True)at = first_divergence(good, bad)print("characters:", len(good), "->", len(bad), "| first divergence at", at)print("--- the assistant turn, broken template:")start = bad.rfind("<|im_start|>", 0, at)end = bad.find("<|im_end|>", at) + len("<|im_end|>")print(visible_whitespace(bad[start:end]))good_ids = tokenizer.encode(good, add_special_tokens=False)bad_ids = tokenizer.encode(bad, add_special_tokens=False)i = first_divergence(good_ids, bad_ids)print("tokens:", len(good_ids), "->", len(bad_ids), "| first divergence at", i)print("correct:", good_ids[i], repr(tokenizer.decode([good_ids[i]])))print("broken: ", bad_ids[i], repr(tokenizer.decode([bad_ids[i]])))

show_tools_defined.py (steps 7 and 8):

python
from naive import CONVERSATIONfrom templatestudio.jinja_env import build_template_env, render_template_stringfrom templatestudio.render import load_tokenizer# Announces tools whenever a variable called tools exists, even if it is None.TOOLS_CHECK = (    "{% if tools is defined %}tools: {{ tools | tojson }}"    "{% else %}no tools variable{% endif %}")tokenizer = load_tokenizer("Qwen/Qwen3-0.6B")production = tokenizer.apply_chat_template(    CONVERSATION, chat_template=TOOLS_CHECK, tokenize=False)bare = build_template_env().from_string(TOOLS_CHECK).render(messages=CONVERSATION)faithful = render_template_string(TOOLS_CHECK, CONVERSATION)print(f"apply_chat_template:    {production!r}")print(f"bare render:            {bare!r}")print(f"render_template_string: {faithful!r}")

show_reasoning.py (step 9):

python
from reasoning_turns import REASONEDfrom templatestudio.jinja_env import render_template_stringfrom templatestudio.render import load_tokenizertokenizer = load_tokenizer("Qwen/Qwen3-0.6B")production = tokenizer.apply_chat_template(REASONED, tokenize=False)local = render_template_string(tokenizer.chat_template, REASONED)print("same string as apply_chat_template:", local == production)print("--- the assistant turn, local render:")print(local[local.rindex("<|im_start|>") :], end="")

show_special_tokens.py (step 10):

python
from naive import CONVERSATIONfrom templatestudio.jinja_env import render_template_stringfrom templatestudio.render import load_tokenizer# Ends every message with the tokenizer's end-of-sequence token.EOS_CHECK = "{% for m in messages %}{{ m['content'] }}{{ eos_token }}\n{% endfor %}"tokenizer = load_tokenizer("Qwen/Qwen3-0.6B")special_tokens = tokenizer.special_tokens_mapprint("special tokens:", special_tokens)for options in [{}, {"eos_token": "[END]"}]:    production = tokenizer.apply_chat_template(        CONVERSATION, chat_template=EOS_CHECK, tokenize=False, **options    )    without = render_template_string(EOS_CHECK, CONVERSATION, **options)    merged = render_template_string(        EOS_CHECK, CONVERSATION, **{**special_tokens, **options}    )    print(f"options {options}:")    print("  without special tokens:", without == production)    print("  with special tokens:   ", merged == production)    print("  first line:", merged.splitlines()[0])

Next steps: whitespace linting, the undefined trap, and Transformers 5.22.2

  • Write a whitespace linter. Write lint_whitespace(rendered: str) -> list[str], which flags a space before <|im_start|> or <|im_end|>, a doubled newline at a turn boundary, and a line that begins with spaces. Run it on step 6's broken render and on the correct one, and decide which findings you would let fail a CI job.
  • Map the undefined trap. Write a template that reads tools[0]['function']['name'], and render it with render_template_string three ways: with TOOLS, with tools=None, and with a bare build_template_env().from_string(...).render(...) that never passes tools. Record which cases raise and which print nothing. Then rewrite the template so all three are safe.
  • Follow Transformers 5.22.2. Version 5.22.2 hardens the sandbox this part copies. Its pull request says a CPython bug can be reached from inside the sandbox on Python versions before 3.13. To close it, 5.22.2 replaces the template's dict and namespace with restricted versions, and it converts every input to plain Python types before rendering. In a separate virtual environment with transformers==5.22.2, run prove_faithful.py and record which rows change. Then read the v5.22.2 chat_template_utils.py and add its restricted dict and namespace globals and its input conversion to a build_template_env_5_22_2, until the table is all True again.

Part 4 of this series uses this renderer to open real templates: the anatomy of a chat template, and the parts every model's template shares.

This tutorial is the runnable slice of chapter 3 of The Chat Templates Handbook. The chapter adds the full working subset of Jinja that chat templates use, the sandbox's limits, and the production failures caused by rendering in the wrong environment. Its code is the v1.2/ch03-just-enough-jinja tag of Template Studio. This tutorial's render_template_string follows Transformers 5.22.1 more closely than the tagged version: it passes tools=None and documents=None, copies reasoning fields, accepts the special tokens as keyword arguments, and defaults add_generation_prompt to False.

References


Llms

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Federated AI Agents cover

Federated AI Agents

Right-Sized AI cover

Right-Sized AI

Harness Engineering for Production AI Systems cover

Harness Engineering

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments