Templates & UI Modules

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Tornado 6.5.10 (released 2026-09-15) on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as the alpha 6.6a1 at that date, so anything about 6.6 is upcoming and may change; nothing on this page depends on it. Tornado ships its own small template engine, tornado.template, and a component mechanism built on top of it, the UI modules. This page covers both, plus the escape hatches (ui_methods, a custom render_string, using Jinja instead). The running scenario is Bookshelf Live: a shelf page that lists books, shows who is signed in, embeds a form carrying an XSRF token and renders each book as a reusable card. The Flask counterpart of this page is Jinja Templates, which uses Jinja rather than a built-in engine; the use of Jinja inside Tornado is described in Using Jinja Instead.

How Tornado Renders HTML

A Tornado template is not interpreted. tornado.template.Template parses the file and compiles it into Python source, then `compile()`s that source once; rendering executes the generated function. The consequences shape everything else on this page:

  • Statements are Python. {% if %}, {% for %}, {% while %} and {% try %} are translated one to one into Python statements, so any Python expression is allowed in them and in {{ }}. There is no sandbox. Templates must be trusted code written by developers, never strings built from user input.

  • Rendering is synchronous. A template (and a UI module) cannot await. Do all I/O in the handler, pass the results in, and keep template logic cheap, because a slow render blocks the event loop for every other connection (see Asynchronous I/O & the Event Loop).

  • Compiled templates are cached by a loader, which is what makes repeated renders fast and what compiled_template_cache controls.

The handler API sits on top: RequestHandler.render(template_name, **kwargs) renders and finishes the response, RequestHandler.render_string(template_name, **kwargs) returns the bytes without writing them. render() calls render_string(), then inserts the CSS and JavaScript that UI modules asked for, then calls finish().

flowchart LR H["handler.render('shelf.html', books=...)"] --> RS["render_string"] RS --> L["Loader
(template_path or template_loader)"] L -- "cache hit or compile" --> T["Template
compiled Python function"] NS["get_template_namespace()
+ ui_methods + ui_modules
+ keyword arguments"] --> T T --> HTML["HTML bytes"] HTML --> INS["insert module resources
CSS before head end,
JS before body end"] INS --> F["finish()"]

A Helper to Run the Examples

All examples below are executed against real loopback servers. This helper starts the application on a free port, makes one request with AsyncHTTPClient and shuts the server down. Test classes based on AsyncHTTPTestCase do the same thing; one is shown at the end of the page.

import asyncio
import pathlib
import secrets
import tempfile

from tornado import escape, template, web
from tornado.httpclient import AsyncHTTPClient
from tornado.httpserver import HTTPServer
from tornado.testing import bind_unused_port


async def _get(app, path, headers=None):
    sock, port = bind_unused_port()
    server = HTTPServer(app)
    server.add_sockets([sock])
    client = AsyncHTTPClient()
    try:
        return await client.fetch(
            f"http://127.0.0.1:{port}{path}", headers=headers, raise_error=False
        )
    finally:
        client.close()
        server.stop()


def get(app, path, headers=None):
    return asyncio.run(_get(app, path, headers))

Template Syntax

Expressions go between double curly braces and are escaped by default; statements use {% %} and, unlike Python, are closed with {% end %} instead of indentation. Comments are {# ... #}. The escaped forms {{!, {%! and {#! print the literal opening delimiter.

Construct Meaning

{{ expression }}

Evaluates any Python expression and inserts the result, escaped with the current autoescape function.

{% if %}, {% elif %}, {% else %}, {% end %}

The Python if statement.

{% for x in y %}, {% while c %}

Loops; {% break %} and {% continue %} work inside them.

{% try %} and {% except %}

The Python try statement, with else and finally.

{% set x = y %}

Sets a local variable.

{% import m %}, {% from m import n %}

The Python import statements.

{% apply fn %}...{% end %}

Passes everything rendered between apply and end through a function, for example linkify.

{% raw expression %}

Inserts the expression without escaping.

{% autoescape fn %}

Sets the escape function (or None) for the current file only.

{% whitespace mode %}

Sets the whitespace mode for the rest of the file.

{% extends "base.html" %}, {% block name %}

Template inheritance, see below.

{% include "part.html" %}

Textually includes another template, sharing the current variables.

{% module expr %}

Renders a UI module; its output is not escaped. See UI Modules.

{% comment text %}

A comment that runs to the closing delimiter and has no {% end %}.

The template body below is rendered directly with Template.generate(), with no handler and no loader. That is enough for plain statements and expressions, but extends, include and module need a loader (and module needs a RequestHandler), which is why real applications go through render().

t = template.Template("""<ul>
{# not in the output #}
{% set total = len(books) %}
{% for b in books %}<li>{{ b['title'] }} ({{ b['year'] }}){% if b['year'] < 1950 %} classic{% end %}</li>
{% end %}</ul>
<p>{{ total }} books. Literal: {{! total }} and {%! if %}</p>
{% apply linkify %}see https://www.tornadoweb.org/ now{% end %}
<p>{% raw "<b>trusted</b>" %} vs {{ "<b>untrusted</b>" }}</p>""")

books = [{"title": "Dune <1965>", "year": 1965}, {"title": "R&R", "year": 1920}]
print(t.generate(books=books).decode())

Two details are easy to miss. First, generate() returns bytes. Second, names beginning with _tt_ are reserved by the engine and must not be used for your own variables or namespace entries.

The Template Namespace

Every template sees a namespace assembled from three layers: the defaults of tornado.template, the result of RequestHandler.get_template_namespace(), and the keyword arguments passed to render(). Later layers win.

Name What it is

escape, xhtml_escape

tornado.escape.xhtml_escape, the default autoescape function.

url_escape

tornado.escape.url_escape; percent-encodes for use inside a URL.

json_encode

tornado.escape.json_encode; JSON that never contains the sequence </, so it is safe inside a script element.

squeeze, linkify

Collapse whitespace; turn plain URLs into links (escaping the text first).

datetime

The standard library module.

handler, request

The current RequestHandler and its HTTPServerRequest.

current_user

The value of RequestHandler.current_user, loaded by get_current_user().

locale, _, pgettext

The request’s Locale, its translate method and its pgettext method; see Localization.

static_url

RequestHandler.static_url; versioned URL of a file under static_path.

xsrf_form_html

RequestHandler.xsrf_form_html; the hidden input with the XSRF token.

reverse_url

RequestHandler.reverse_url; URL of a named route.

modules

A lazy namespace of the registered UI modules (the {% module %} directive uses the equivalent _tt_modules).

every key of ui_methods

Application-wide helper functions, see ui_methods.

To add application-wide values, override get_template_namespace() in a base handler and call super(). Values that depend on one page are simply keyword arguments to render().

Template Files, template_path and Inheritance

Real applications keep templates in a directory named by the template_path application setting. Paths passed to render(), {% extends %}, {% include %} and {% module Template() %} are resolved relative to it; names used by include and extends are first tried relative to the directory of the including file and, if that does not stay inside the root, from template_path. Individual handlers can return another directory from get_template_path(), for example to give an admin area its own templates; returning None makes Tornado load templates relative to the file that calls render().

Settings that influence templates are all Application settings (keyword arguments of the constructor):

Setting Default Effect

template_path

none

Directory searched by the default tornado.template.Loader.

template_loader

none

An instance of a tornado.template.BaseLoader used instead of the default one, for every handler. A template_path is then not needed.

autoescape

"xhtml_escape"

Name of the escape function applied to every {{ }}; None disables autoescaping for the whole application.

template_whitespace

"single" for .html and .js files, "all" otherwise

How whitespace is treated in templates, see Whitespace.

compiled_template_cache

True

Keep compiled templates in the loader. False recompiles on every request. debug=True sets it to False (unless you set it yourself).

ui_modules, ui_methods

empty

Registers UI modules and helper functions, see below.

extends, block, include and raw

{% extends %} makes a template a child of another one. Everything in the child outside a {% block %} is ignored; the blocks replace the same-named blocks of the parent. {% include %} pastes another template in place and shares the current local variables (apart from the autoescape directive). {% module Template(...) %} renders a template with an isolated namespace instead; it is described with the UI modules.

The next example is the whole Bookshelf Live shelf page. The TEMPLATES dictionary stands in for the files of a templates/ directory (the helper writes them to a temporary directory), and STATIC for static/. The page uses current_user, reverse_url, static_url, locale, _, an included footer and the XSRF form field.

TEMPLATES = {
    "base.html": """<!doctype html>
<html lang="{{ locale.code.replace('_', '-') }}">
<head>
  <meta charset="utf-8">
  <title>{% block title %}Bookshelf Live{% end %}</title>
  <link rel="stylesheet" href="{{ static_url('css/app.css') }}">
</head>
<body>
  <header>{% if current_user %}Signed in as {{ current_user }}{% else %}<a href="{{ reverse_url('login') }}">Sign in</a>{% end %}</header>
  <main>
{% block content %}{% end %}
  </main>
  {% include "footer.html" %}
</body>
</html>
""",
    "footer.html": """<footer>{{ site_name }}: {{ len(books) }} {{ _("books on the shelf") }}</footer>
""",
    "shelf.html": """{% extends "base.html" %}
{% block title %}{{ shelf_title }} - Bookshelf Live{% end %}
{% block content %}
<h1>{{ shelf_title }}</h1>
<ul>
{% for book in books %}
  <li><a href="{{ reverse_url('book', book['id']) }}">{{ book['title'] }}</a> by {{ book['author'] }}</li>
{% end %}
</ul>
<form method="post" action="/books">
  {% module xsrf_form_html() %}
  <input name="title"><button>Add</button>
</form>
{% end %}
""",
}
STATIC = {
    "css/app.css": "body { font-family: sans-serif; }\n",
    "css/book-card.css": ".book-card { border: 1px solid #888; }\n",
    "js/cards.js": "console.log('cards');\n",
    "js/rating.js": "console.log('rating');\n",
}


def write_tree(root, files):
    for name, content in files.items():
        path = root / name
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")


SITE = pathlib.Path(tempfile.mkdtemp(prefix="bookshelf-live-"))
write_tree(SITE / "templates", TEMPLATES)
write_tree(SITE / "static", STATIC)

BOOKS = [
    {"id": 1, "title": "Dune <Part 1>", "author": "Frank Herbert", "rating": 5},
    {"id": 2, "title": "R&R", "author": "A. Writer", "rating": 3},
]


class BaseHandler(web.RequestHandler):
    def get_current_user(self):
        value = self.get_signed_cookie("user")
        return value.decode() if value else None

    def get_template_namespace(self):
        namespace = super().get_template_namespace()
        namespace.update(site_name="Bookshelf Live")
        return namespace


class ShelfHandler(BaseHandler):
    def get(self):
        self.render("shelf.html", shelf_title="Reading now", books=BOOKS)


class BookHandler(BaseHandler):
    def get(self, book_id):
        self.write(f"book {book_id}")


class LoginHandler(BaseHandler):
    def get(self):
        self.write("login form")


COOKIE_SECRET = secrets.token_hex(32)  # in production: read it from the environment


def make_app(**overrides):
    settings = dict(
        template_path=str(SITE / "templates"),
        static_path=str(SITE / "static"),
        cookie_secret=COOKIE_SECRET,
        xsrf_cookies=True,
    )
    settings.update(overrides)
    return web.Application(
        [
            web.url(r"/", ShelfHandler, name="shelf"),
            web.url(r"/books/([0-9]+)", BookHandler, name="book"),
            web.url(r"/login", LoginHandler, name="login"),
        ],
        **settings,
    )


app = make_app()
signed = web.create_signed_value(COOKIE_SECRET, "user", "ana").decode()
response = get(app, "/", headers={"Cookie": f"user={signed}"})
print(response.code)
print(response.body.decode())

The output shows the inheritance resolved, the title block replaced, Dune <Part 1> and R&R escaped, the login link replaced by the signed-in name and the static_url link carrying a content hash in a v query parameter. When the cookie is absent, current_user is None and the "Sign in" link is rendered instead.

Autoescaping and Its Limits

Every {{ }} expression is passed through xhtml_escape, which replaces &, <, >, " and ' with entities (since Tornado 6.4 it is a thin wrapper around html.escape, so a single quote becomes &#x27; rather than &#39;). This neutralises injected markup in HTML text and in quoted attribute values. It is not a universal defence: the escape function only knows HTML. The context decides whether the result is safe.

Where the value lands Problem with plain {{ }} What to do

Element text

Safe.

Nothing extra.

Quoted attribute value

Safe. xhtml_escape escapes both quote characters, but an unquoted attribute stays injectable.

Always quote attributes.

URL attribute (href, src)

javascript: URLs survive escaping untouched.

Validate the scheme in Python (allow http, https), and use url_escape for query-string values.

Inside a script element or event handler

HTML entities are not decoded inside a script element, and JavaScript string quoting is different.

Pass data with {% raw json_encode(value) %}, or put it in a data- attribute or a JSON script block.

Inside a style element or style attribute

CSS has its own escapes; HTML escaping is neither necessary nor sufficient.

Never interpolate untrusted values; choose from a fixed set of classes.

Output of a UI module or {% raw %}

Not escaped at all.

Escape inside the module’s own template, or pass trusted markup only.

The following example puts a hostile value in each context. The value </script><script>alert(1)</script> is rendered once as text, once in a JavaScript context the wrong way, and once the right way with json_encode, which never emits the sequence </ and so cannot close the surrounding script element.

t = template.Template("""<p>{{ v }}</p>
<script>var wrong = "{{ v }}";</script>
<script>var right = {% raw json_encode(v) %};</script>
<a href="/search?q={{ url_escape(v) }}">search</a>""")
hostile = "</script><script>alert(1)</script>"
print(t.generate(v=hostile).decode())

The "wrong" line is also a bug without any attacker: a title such as Tom's book becomes Tom&#x27;s book inside the JavaScript string. The right line is valid JavaScript.

To disable escaping, use {% raw expr %} for one expression, {% autoescape None %} for one file, or the autoescape setting with None for the whole application. Disabling it globally is almost never a good idea; a better pattern is to leave it on and mark individual trusted fragments with raw. An alternative escape function can be registered in the namespace and selected by name, for example {% autoescape my_escape %} or autoescape="my_escape", which is how a JSON-only or plain-text template can use a different policy. Templates not ending in .html are still autoescaped (unlike Flask, which keys the default on file extension).

Loaders, Caching and Whitespace

The default loader is tornado.template.Loader(root_directory). It resolves names to files under the root, compiles each file once and caches the Template. DictLoader(dict) does the same for templates held in a dictionary, which is handy in tests. Both extend BaseLoader, whose load(name, parent_path=None) and reset() methods are the public interface, and a custom loader overrides resolve_path and _create_template. A loader constructor also takes autoescape, namespace (extra names for every template) and whitespace.

Pass an instance as the template_loader setting to replace the default. The following loader serves templates from a dictionary and adds a global name to every template:

loader = template.DictLoader(
    {"hello.html": "<p>{{ greeting }}, {{ name }}! {{ BRAND }}</p>"},
    namespace={"BRAND": "Bookshelf Live"},
)


class HelloHandler(web.RequestHandler):
    def get(self):
        self.render("hello.html", greeting="Hello", name="<reader>")


dict_app = web.Application(
    [(r"/hello", HelloHandler)],
    template_loader=loader,
)
print(get(dict_app, "/hello").body.decode())

compiled_template_cache and Debug Mode

Because the loader caches compiled templates, editing a file has no effect on a running server unless the cache is off. debug=True turns compiled_template_cache (and static_hash_cache) off, together with autoreload, which is why edits show up immediately during development and why development timings are not representative. In production leave the cache on. The next example proves the behaviour by editing a template between two requests:

class EditedHandler(web.RequestHandler):
    def get(self):
        self.render("edited.html")


edit_dir = SITE / "edit"
edit_dir.mkdir()
(edit_dir / "edited.html").write_text("version 1")
results = {}
for cache in (True, False):
    app_edit = web.Application(
        [(r"/", EditedHandler)],
        template_path=str(edit_dir),
        compiled_template_cache=cache,
    )

    async def two_requests(app=app_edit):
        sock, port = bind_unused_port()
        server = HTTPServer(app)
        server.add_sockets([sock])
        client = AsyncHTTPClient()
        try:
            url = f"http://127.0.0.1:{port}/"
            first = (await client.fetch(url)).body.decode()
            (edit_dir / "edited.html").write_text("version 2")
            second = (await client.fetch(url)).body.decode()
            (edit_dir / "edited.html").write_text("version 1")
            return first, second
        finally:
            client.close()
            server.stop()

    # reset the process-wide loader cache between the two runs
    web.RequestHandler._template_loaders.clear()
    results[cache] = asyncio.run(two_requests())
print(results)

With the cache on the second response still says version 1; with it off, version 2. (The example clears the private _template_loaders class attribute only to restart cleanly inside one process; application code never needs to.) The loaders are shared per template directory across all RequestHandler instances in the process, so the cache is also shared between applications that use the same template_path.

Whitespace Handling

The template_whitespace setting (and the whitespace argument of Loader, DictLoader and Template, and the {% whitespace mode %} directive) selects how runs of whitespace in the template source are emitted:

Mode Behaviour

all

Everything is kept unchanged.

single

Runs of spaces and tabs collapse to one space, and a newline with its surrounding whitespace collapses to a single newline. This is the default for files ending in .html or .js.

oneline

All whitespace runs, including newlines, collapse to one space. Useful for HTML meant to be small, but it will change the meaning of pre elements and line-oriented text such as .txt or .csv templates (which default to all).

The directive was added in Tornado 4.3; the old boolean compress_whitespace argument is deprecated since the same version. A quick comparison:

source = "<ul>\n    <li>a</li>\n    <li>b</li>\n</ul>\n"
for mode in ("all", "single", "oneline"):
    print(mode, repr(template.Template(source, whitespace=mode).generate().decode()))

UI Modules

A UI module is a reusable fragment of page: a class with a render() method that returns markup, optionally together with the CSS and JavaScript it needs. It is the Tornado answer to "I use this card in six places and want its styles and scripts to come along exactly once", and the nearest equivalent in Flask is a Jinja macro or include combined with manual asset wiring.

Subclass tornado.web.UIModule, register the class in the ui_modules setting and invoke it from a template with {% module Name(arguments) %}. The arguments are passed to render(). A module instance has handler, request, current_user, locale and ui attributes and a render_string() method that renders a template with the module’s handler. The module’s return value is inserted without escaping, so the usual pattern is for render() to delegate to a template that escapes, as in this example.

Method Purpose

render(*args, **kwargs)

Required. Returns the HTML for the module.

css_files()

Iterable of stylesheet paths. Relative paths go through static_url; absolute URLs are used as they are.

javascript_files()

Same for scripts.

embedded_css()

A CSS string inserted in a style element in the head.

embedded_javascript()

A JavaScript string inserted in a script element before the end of the body.

html_head(), html_body()

Raw HTML inserted before the end of the head and body.

TEMPLATES.update({
    "modules/book_card.html": """<article class="book-card{% if highlight %} featured{% end %}">
  <h2>{{ book['title'] }}</h2>
  <p>{{ book['author'] }}</p>
</article>
""",
    "cards.html": """{% extends "base.html" %}
{% block content %}
{% for book in books %}
  {% module BookCard(book, highlight=book['rating'] >= 5) %}
{% end %}
{% end %}
""",
})
write_tree(SITE / "templates", TEMPLATES)


class BookCard(web.UIModule):
    def render(self, book, highlight=False):
        return self.render_string("modules/book_card.html", book=book, highlight=highlight)

    def css_files(self):
        return ["css/book-card.css", "https://cdn.example.test/ui.css"]

    def embedded_css(self):
        return ".book-card.featured { border-width: 3px; }"

    def javascript_files(self):
        return "js/cards.js"

    def embedded_javascript(self):
        return "document.title += ' (cards)';"


class CardsHandler(BaseHandler):
    def get(self):
        self.render("cards.html", books=BOOKS)


cards_app = make_app(ui_modules={"BookCard": BookCard})
cards_app.add_handlers(r".*", [web.url(r"/cards", CardsHandler)])
print(get(cards_app, "/cards").body.decode())

In the output the two cards are rendered in place, and the resources appear once even though the module was used twice: the stylesheet links and the embedded style before the end of the head, and the script link and the embedded script before the end of the body. The order of css_files and javascript_files is preserved and duplicates are removed. Two facts about this machinery matter in practice:

  • A module is instantiated once per handler (per request) and kept by name, so render() is called on the same object for every {% module %} of that name on the page, and the resource methods are called once, after the template has rendered. Do not rely on the resource methods seeing the arguments of one particular call.

  • The insertion points are found by searching the finished HTML for the last </body> and the first </head>. A page that uses modules with resources but lacks those tags makes render() raise an error (for example from a partial or an HTMX fragment). Render fragments with render_string() and write them yourself, or use a module without resources.

The embedded script is inline JavaScript. A strict Content Security Policy that forbids inline scripts (for instance script-src 'self') blocks it; prefer javascript_files() when you adopt such a policy.

The Built-in Template Module and set_resources

Registering a class for every fragment is heavy when all you want is "include this template with its own variables". Tornado provides {% module Template("path.html", **kwargs) %} for this. It differs from {% include %} in that the fragment gets its own namespace, containing only the keyword arguments, so it cannot accidentally read or overwrite the page’s variables. Inside such a template, the function set_resources(...) registers the same kinds of resources as the UIModule methods, with keyword arguments css_files, embedded_css, javascript_files, embedded_javascript, html_head and html_body. Resources are output once per template file, not once per use, so they must not depend on the arguments.

TEMPLATES.update({
    "modules/rating.html": """{{ set_resources(embedded_css=".stars { color: #c80; }", javascript_files="js/rating.js") }}<span class="stars">{{ "★" * rating }}</span>""",
    "rated.html": """{% extends "base.html" %}
{% block content %}
{% for book in books %}
  <p>{{ book['title'] }} {% module Template("modules/rating.html", rating=book['rating']) %}</p>
{% end %}
{% end %}
""",
})
write_tree(SITE / "templates", TEMPLATES)


class RatedHandler(BaseHandler):
    def get(self):
        self.render("rated.html", books=BOOKS)


rated_app = make_app()
rated_app.add_handlers(r".*", [web.url(r"/rated", RatedHandler)])
body = get(rated_app, "/rated").body.decode()
print(body.count("rating.js"), body.count(".stars { color"), "★★★★★" in body)

The script link and the style appear once although two modules were rendered. The Template module, linkify and xsrf_form_html are the three modules Tornado registers itself, which is why {% module xsrf_form_html() %} works in the shelf template: the module version of the form field does not escape its output, whereas {{ xsrf_form_html() }} would print the input element as visible text. Both XSRF and current_user are discussed in Authentication & Signed Cookies and XSRF & Cross-Origin Protection.

ui_methods

UI methods are plain functions registered in the ui_methods setting. Unlike a module they have no lifecycle and no resources; they appear in the namespace under their own names and are called like any function. Tornado calls them with the current handler as the first argument, which you do not pass from the template. The setting accepts a dictionary, a module or a list of them; only callables whose names do not start with an underscore or an upper-case letter are registered.

def money(handler, cents):
    symbol = {"EUR": "€", "USD": "$"}.get(handler.get_query_argument("currency", "USD"), "$")
    return f"{symbol}{cents / 100:.2f}"


class PriceHandler(web.RequestHandler):
    def get(self):
        self.render("price.html", cents=1299)


price_app = web.Application(
    [(r"/price", PriceHandler)],
    template_loader=template.DictLoader({"price.html": "<p>{{ money(cents) }}</p>"}),
    ui_methods={"money": money},
)
print(get(price_app, "/price?currency=EUR").body.decode())
print(get(price_app, "/price").body.decode())

The module versus method decision is simple. Use a UI method for a small pure formatting function, a UI module for anything with markup of its own or with assets, and a template include for static layout pieces. A function that is the same in every request and needs no handler can instead be added to the template namespace with a loader’s namespace argument.

Using Jinja Instead

Tornado does not integrate with Jinja, and does not need to: a template engine only has to turn a name and some values into a string. The documented approach is to render with the other engine and write the string yourself. For a cleaner integration, override render_string() in a base handler, so every self.render() in the application goes through Jinja, render() still looks for UI-module resources, which will not be found because Jinja templates do not use {% module %}, so nothing extra is inserted.

import jinja2

jinja_env = jinja2.Environment(
    loader=jinja2.DictLoader({"jshelf.html": "<h1>{{ title }}</h1>{% for b in books %}<p>{{ b.title }}</p>{% endfor %}"}),
    autoescape=jinja2.select_autoescape(default=True, default_for_string=True),
)


class JinjaHandler(web.RequestHandler):
    def render_string(self, template_name, **kwargs):
        namespace = self.get_template_namespace()
        namespace.update(kwargs)
        return jinja_env.get_template(template_name).render(**namespace).encode()

    def get(self):
        self.render("jshelf.html", title="Jinja shelf", books=BOOKS)


jinja_app = web.Application([(r"/", JinjaHandler)])
print(get(jinja_app, "/").body.decode())

Jinja’s own escaping is configured separately (select_autoescape above); Tornado’s autoescape setting does not apply to Jinja templates. The namespace carries static_url, reverse_url, current_user and the other names unchanged, so {{ static_url("css/app.css") }} works in Jinja too. Switching is worthwhile mainly for sandboxing (SandboxedEnvironment) when templates are not trusted, for filters, macros and a larger ecosystem of extensions, or when a team already knows Jinja from Flask. Keep Tornado’s own engine when you want zero dependencies, UI modules with automatic asset insertion, and templates that are plain Python. Jinja’s render is synchronous too, so the event loop warning above applies; Jinja’s async mode is a different rendering API and is not needed for this pattern.

Testing Templates

Test through the handler, with AsyncHTTPTestCase: assert on the status, on fragments of the body and on the escaping of hostile data. Template syntax errors raise ParseError when the template is first loaded, so a request for each page in the test suite is the cheapest syntax check.

import unittest

from tornado.testing import AsyncHTTPTestCase


class ShelfTests(AsyncHTTPTestCase):
    def get_app(self):
        return make_app()

    def test_escapes_titles(self):
        body = self.fetch("/").body.decode()
        self.assertIn("Dune &lt;Part 1&gt;", body)
        self.assertNotIn("<Part 1>", body)

    def test_anonymous_sees_login_link(self):
        self.assertIn('href="/login"', self.fetch("/").body.decode())

    def test_form_has_xsrf_input(self):
        self.assertIn('<input type="hidden" name="_xsrf"', self.fetch("/").body.decode())


suite = unittest.defaultTestLoader.loadTestsFromTestCase(ShelfTests)
outcome = unittest.TextTestRunner(verbosity=0).run(suite)
assert outcome.wasSuccessful()

Pitfalls

  • Untrusted template source. A template can run arbitrary Python. Never build template text from user input (server-side template injection is remote code execution) and never let users upload templates to Tornado’s engine; use a sandboxed Jinja environment for that.

  • Escaping applied once, in the wrong place. Escape on output, in the template. Storing pre-escaped text in the database produces double escaping later and does not protect other contexts.

  • {{ }} versus {% module %} for markup. A function that returns HTML, such as xsrf_form_html(), is escaped and shown as text when printed with the double-brace form; the {% module %} and {% raw %} forms do not escape at all, so the module is responsible for escaping anything user-controlled it emits.

  • HTML-only escaping in JavaScript, CSS and URL contexts, see Autoescaping and Its Limits.

  • Blocking work in a template or module. A database call inside render() blocks the loop. Fetch data in the handler.

  • Missing </head> or </body> in a page that uses modules with assets, see UI Modules.

  • Cache confusion. The compiled cache is on by default; a deployment that edits templates in place will serve old versions until restart. Restart (or roll) the processes on deploy rather than turning the cache off.

  • Reserved names. Do not use escape, url_escape, json_encode, squeeze, linkify, datetime, handler, request, modules, locale or names starting with _tt_ as variables.

Recent Changes and Version Notes

Version Change

4.3

{% whitespace %}, the whitespace argument and the template_whitespace setting added; compress_whitespace deprecated.

5.1

RequestHandler.render() returns a Future (awaiting it is optional).

6.4

xhtml_escape wraps html.escape; a single quote is now &#x27;. Tests that compared against &#39; need updating.

6.5.10

Current stable release used on this page.

6.6 (upcoming)

Only an alpha exists on 2026-10-07. Check the release notes for template-related changes before relying on any behaviour described here when you upgrade.

Static Files, Localization (the _() function and pgettext), XSRF & Cross-Origin Protection and Authentication & Signed Cookies describe static files, localization, and the security features behind xsrf_form_html and current_user.