Chameleon renders synchronously. In an async web app (FastAPI, Starlette, Quart, Litestar, Robyn) that means a render runs on the event loop, and nothing else in that worker runs until it finishes. chameleon-rs keeps render() exactly as it was and adds render_async(), which renders on the event loop without holding it while it waits for data.
The framework integrations use it for you: an async def view decorated with @template renders with render_async. This page explains what that does, and the opt-in extras built on it.
await template.render_async(**model) renders like render(), except that when a value the template uses is awaitable (a coroutine, task or future), the render awaits it right there and continues with the result. While it waits, the event loop serves other requests.
import asyncio
from chameleon_rs import PageTemplate
page = PageTemplate("""\
<h1>${title}</h1>
<ul>
<li tal:repeat="episode episodes">${episode}</li>
</ul>
""")
async def latest_episodes():
await asyncio.sleep(0.01) # a database query, say
return ['Rust for Python devs', 'Async templates']
print(await page.render_async(title='Latest', episodes=latest_episodes()))
<h1>Latest</h1>
<ul>
<li>Rust for Python devs</li>
<li>Async templates</li>
</ul>
latest_episodes() is passed unawaited. The render reaches tal:repeat, awaits it, and repeats over the list it returns.
With no awaitable values, render_async() gives exactly render()’s output. That’s tested against every Chameleon fixture template and the render fuzzers, so switching a view from render() to render_async() changes nothing until you pass it something to await.
You don’t have to hand coroutines to the template. Loading the data in the view (or a view model) with asyncio.gather keeps data access in one place and makes the template a pure function of its model:
async def episode_page(number: int):
episode, related = await asyncio.gather(db.episode(number), db.related(number))
return {'episode': episode, 'related': related}
render_async still matters then: a big page renders without blocking the loop (see Offloading), and the integrations need no change in your views. Passing awaitables to the template is useful when a value is only sometimes used (a sidebar behind a tal:condition), so it is never loaded when the page doesn’t show it.
What gets awaited
Only an expression’s final value is awaited:
${episodes}, tal:content="episodes" |
Awaited, then inserted. |
tal:repeat="e episodes" |
Awaited, then repeated over. |
tal:condition="episodes" |
Awaited, and the condition tested on the result. |
tal:define="items episodes" |
Awaited once; items is the result. |
${episodes.count} |
Not awaited: .count is looked up on the coroutine, an AttributeError as in a sync render. |
${len(episodes)} |
Not awaited: len() gets the coroutine. |
So pass the awaitable the template needs as a whole, or tal:define it first and use the result:
page = PageTemplate("""\
<p tal:define="items episodes">${len(items)} episodes, the first is ${items[0]}.</p>
""")
print(await page.render_async(episodes=latest_episodes()))
<p>2 episodes, the first is Rust for Python devs.</p>
Async iterables (an async def generator) aren’t supported in tal:repeat, as they aren’t iterable in a sync render either. Collect them in a helper first: [x async for x in source].
Errors, macros and partials
Everything in the language works across a wait:
- Macros and slots. A layout’s macro and the page’s slot fills can await values.
- Partials.
${render_partial('shared/card.pt', item=item)} renders natively, and the partial can await values of its own.
tal:on-error. An exception raised by an awaited value is raised where the expression is, so the nearest tal:on-error handles it, as for any other error.
- Cancellation. Cancelling the task that runs the render cancels the value being awaited.
async def broken():
raise LookupError('the recommendations service is down')
page = PageTemplate("""\
<aside tal:on-error="string:Recommendations are unavailable.">
<p tal:repeat="r recommendations">${r}</p>
</aside>
""")
print(await page.render_async(recommendations=broken()))
<aside>Recommendations are unavailable.</aside>
Running helpers concurrently
By default the render awaits one value at a time, in document order. A page that calls three slow helpers waits for each in turn. Two opt-in settings let them run at the same time. Both change the order in which your code runs, which is why they’re off by default.
@chameleon_rs.concurrent helpers
Decorate an async helper with @chameleon_rs.concurrent, and each call to it written as a whole ${...} starts as a task. The render writes a placeholder and keeps going, and each placeholder is filled in, escaped as usual, when the render reaches the end:
import time
import chameleon_rs
@chameleon_rs.concurrent
async def fetch(name, delay):
await asyncio.sleep(delay)
return f'{name} ({delay}s)'
page = PageTemplate("""\
<p>${fetch('downloads', 0.2)}</p>
<p>${fetch('reviews', 0.2)}</p>
<p>${fetch('sponsors', 0.2)}</p>
""")
start = time.perf_counter()
print(await page.render_async(fetch=fetch))
print(f'{time.perf_counter() - start:.1f}s, not 0.6s')
<p>downloads (0.2s)</p>
<p>reviews (0.2s)</p>
<p>sponsors (0.2s)</p>
0.2s, not 0.6s
A call runs concurrently only where its output can simply be written later: a whole ${...} in text, or one inside an attribute value. Anywhere its value decides what the template does (a condition, a tal:define, tal:content / tal:replace, which may choose the default content) or where the output might be taken back (inside tal:on-error or a translation), it is awaited in place, like any awaitable.
If a concurrent call raises, the error is raised when the render reaches the end, in document order, and the other tasks are cancelled. In a sync render() the decorator has no effect.
Partials in parallel: concurrent=True
A template created with concurrent=True also renders its render_partial(...) calls concurrently, when a call has an awaitable argument. Each such partial renders as a render of its own, in a task:
import pathlib, tempfile
folder = pathlib.Path(tempfile.mkdtemp())
(folder / 'card.pt').write_text('<div class="card">${data}</div>')
chameleon_rs.configure(template_folder=str(folder))
async def load(name):
await asyncio.sleep(0.2)
return name
page = PageTemplate("""\
<section tal:repeat="name names">${render_partial('card.pt', data=load(name))}</section>
""", concurrent=True)
start = time.perf_counter()
print(await page.render_async(names=['a', 'b', 'c'], load=load))
print(f'{time.perf_counter() - start:.1f}s')
<section><div class="card">a</div></section>
<section><div class="card">b</div></section>
<section><div class="card">c</div></section>
0.2s
The integrations take the same setting on the decorator: @template('page.pt', concurrent=True), and TemplateResponse_async(..., concurrent=True) for Starlette’s Templates.
Offloading a render to a thread
Experimental. render_async(..., offload=True) (or @template(..., offload=True)) runs the render in a worker thread (the event loop’s default executor) and awaits it. The render is synchronous there, so awaitable values aren’t awaited: load the data first.
- On free-threaded Python (3.14t, 3.15t) renders then run in parallel with the event loop and with each other. chameleon-rs ships free-threaded wheels and doesn’t turn the GIL back on.
- On a regular (GIL) build it helps only pages dominated by long text, whose escaping releases the GIL. For most pages, a thread hop costs more than it saves.
In the web frameworks
Every integration renders async def views with render_async, so all of the above applies to the model a view returns:
Each module also has render_async() and response_async(), the coroutine versions of render() and response().
chameleon-flask, fastapi-chameleon and chameleon-robyn rendered async views synchronously, so an awaitable in the model was inserted as the object itself (<coroutine object ...>). The built-ins await it. That includes test doubles such as AsyncMock() in a model.
Things to know
markdown: and other expression types follow the same rule: the expression’s final value is awaited. Load Markdown text before rendering rather than passing a coroutine for markdown: to convert.
- A subclass that overrides render() keeps its override: render_async() calls it, synchronously, so awaitable values aren’t awaited. A monkeypatched PageTemplate.render (to time renders, say) counts as an override. See Migrating.
- render() never awaits. In a sync render a coroutine in the model is just an object, as in Chameleon.