Skip to content
aviral gupta

// A5.1 · ~30 min · Advanced

Template strings (t-strings)

After this lesson you can read a t-string as a Template of static strings and interpolations, write a renderer that applies conversions and format specs, and escape untrusted values where an f-string would have mixed them in.

Lesson 1 of 5 in A5 Modern Python and safe code

Start of the module

You will be able to

  • Explain what a t-string evaluates to: a Template with strings, interpolations and values
  • Write a renderer that walks a Template and applies each conversion and format spec
  • Explain why a renderer can escape untrusted values in a t-string but not in an f-string
  1. Warm-up · Activity 1 of 7

    Warm-up from module A4 and the f-string lesson: what does this print?

    from decimal import Decimal
    
    price = Decimal("0.1") + Decimal("0.2")
    print(f"{price} {0.1 + 0.2:.2f}")
  2. Predict · Activity 2 of 7

    Predict before you read on: the same braces, but a t prefix. What does this print?

    name = "Ada"
    greeting = t"Hello {name}!"
    print(type(greeting).__name__, greeting.strings)
  3. Practice · Activity 3 of 7

    shout() upper-cases only the interpolated values. Fill in the class to test each part against.

    from string.templatelib import Interpolation
    
    
    def shout(template):
        parts = []
        for part in template:
            if isinstance(part, ____):
                parts.append(str(part.value).upper())
            else:
                parts.append(part)
        return "".join(parts)
    if isinstance(part, ):
  4. Practice · Activity 4 of 7

    Conversions and format specs are stored, not applied. What does this print?

    x = "hi"
    tpl = t"{x!r:>6}"
    i = tpl.interpolations[0]
    print(i.value, i.conversion, i.format_spec)
  5. Practice · Activity 5 of 7

    x = 1 and y = 2. Match each expression to its value.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. What does this print?

    name = "Ada"
    greeting = t"Hi {name}!"
    print(greeting == "Hi Ada!", "".join(greeting.strings))
  7. Apply · Activity 7 of 7

    Mini-task, a structured log line. Write log_line(template) that renders the text and then appends " | " and one expression=repr(value) pair per interpolation. log_line(t"{user} bought {n} books") with user = "ada" and n = 3 must give: ada bought 3 books | user='ada' n=3

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

An HTML renderer that escapes only the values

render_html() walks a t-string. It applies each conversion and format spec as an f-string would, then escapes the result with html.escape, and copies the static markup unchanged. The user name contains angle brackets, the kind of text an attacker would try. The last line shows the f-string version for contrast: once it is a str, the markup the programmer wrote and the text the user typed can no longer be told apart.

main.py

import html
from string.templatelib import Interpolation, Template, convert


def render_html(template: Template) -> str:
    """Join a template, escaping every interpolated value for HTML."""
    parts: list[str] = []
    for part in template:
        if isinstance(part, Interpolation):
            value = convert(part.value, part.conversion)
            text = format(value, part.format_spec)
            parts.append(html.escape(text))
        else:
            parts.append(part)  # static text, written by the programmer
    return "".join(parts)


user = "Ada <admin>"
total = 1234.5

page = t"<p>Hello {user}, you owe {total:,.2f} EUR</p>"
print(page.strings)
print(page.values)
print(render_html(page))
print(f"<p>Hello {user}</p>")  # an f-string has already merged the parts

Run it with

python main.py

Output

('<p>Hello ', ', you owe ', ' EUR</p>')
('Ada <admin>', 1234.5)
<p>Hello Ada &lt;admin&gt;, you owe 1,234.50 EUR</p>
<p>Hello Ada <admin></p>
  • strings holds the three static parts; values holds the two values, unformatted.
  • The format spec ,.2f is applied by format() inside the renderer, not by the t-string.
  • Only the value is escaped: <p> stays markup, <admin> becomes &lt;admin&gt;.
  • The f-string line passes the user text straight into the markup.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

Render like an f-string

Complete render(template) in main.py so that it returns exactly what the matching f-string would give. The starter inserts str(value) and ignores the conversion and the format spec. Apply part.conversion with convert() and then part.format_spec with format(). The tests compare with f-strings, including the = specifier.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    convert(part.value, part.conversion) returns the value unchanged when conversion is None, and str(), repr() or ascii() of it otherwise.

  2. Hint 2

    format(value, "") is the same as str(value), so format(converted, part.format_spec) works when there is no spec too.

  3. Hint 3

    The = specifier needs no code of its own: the t-string already puts "n=" into strings and sets conversion to "r".

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from string.templatelib import Interpolation, Template, convert


def render(template: Template) -> str:
    """Render a template exactly like the matching f-string."""
    out: list[str] = []
    for part in template:
        if isinstance(part, Interpolation):
            value = convert(part.value, part.conversion)
            out.append(format(value, part.format_spec))
        else:
            out.append(part)
    return "".join(out)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from string.templatelib import Interpolation, Template, convert


def render(template: Template) -> str:
    """Render a template exactly like the matching f-string."""
    out: list[str] = []
    for part in template:
        if isinstance(part, Interpolation):
            # Apply part.conversion (with convert) and part.format_spec (with format).
            out.append(str(part.value))
        else:
            out.append(part)
    return "".join(out)

test_main.py

from main import render


def test_plain():
    """A plain value is inserted as it is"""
    name = "Ada"
    got = render(t"Hi {name}!")
    assert got == "Hi Ada!", f"render gave {got!r}, expected 'Hi Ada!'"


def test_format_spec():
    """The format spec is applied, as in an f-string"""
    price = 3.14159
    got = render(t"{price:.2f} EUR")
    assert got == "3.14 EUR", f"render gave {got!r}: apply part.format_spec with format()"


def test_conversion():
    """!r is applied, as in an f-string"""
    word = "hi"
    got = render(t"say {word!r}")
    assert got == "say 'hi'", f"render gave {got!r}: apply part.conversion with convert()"


def test_same_as_fstring():
    """The = specifier and a width give what the f-string gives"""
    n = 5
    got = render(t"{n=} [{n:>4}]")
    want = f"{n=} [{n:>4}]"
    assert got == want, f"render gave {got!r}, the f-string gives {want!r}"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Escape the values, keep the markup

Complete safe_html(template). Escape every interpolated value with html.escape, after applying its conversion and format spec, and keep the static markup unchanged. If the argument is not a Template, for example the str from an f-string, raise TypeError: a renderer that accepted a str could not know which parts to escape.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Start with if not isinstance(template, Template): raise TypeError(...).

  2. Hint 2

    Loop over the template. For an Interpolation, format it as in the previous exercise and append html.escape(text); append a str part unchanged.

  3. Hint 3

    html.escape escapes <, >, & and, by default, both kinds of quotes.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import html
from string.templatelib import Interpolation, Template, convert


def safe_html(template: Template) -> str:
    """Build HTML from a t-string, escaping every interpolated value."""
    if not isinstance(template, Template):
        raise TypeError("safe_html needs a t-string, not a str")
    out: list[str] = []
    for part in template:
        if isinstance(part, Interpolation):
            text = format(convert(part.value, part.conversion), part.format_spec)
            out.append(html.escape(text))
        else:
            out.append(part)
    return "".join(out)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

import html
from string.templatelib import Interpolation, Template, convert


def safe_html(template: Template) -> str:
    """Build HTML from a t-string, escaping every interpolated value."""
    # This escapes the static markup too, ignores the format specs,
    # and quietly accepts a plain str.
    text = "".join(str(part.value) if isinstance(part, Interpolation) else part for part in template)
    return html.escape(text)

test_main.py

from main import safe_html


def test_escapes_values():
    """Markup in a value is escaped"""
    name = "<b>Ada</b>"
    got = safe_html(t"<p>{name}</p>")
    assert got == "<p>&lt;b&gt;Ada&lt;/b&gt;</p>", f"safe_html gave {got!r}"


def test_keeps_static_markup():
    """The markup written in the t-string itself stays markup"""
    city = "Bonn"
    got = safe_html(t'<a href="/map">{city}</a>')
    assert got == '<a href="/map">Bonn</a>', f"safe_html gave {got!r}: escape only the interpolations"


def test_quotes_escaped():
    """Quotes in a value are escaped too"""
    title = 'say "hi"'
    got = safe_html(t"<b>{title}</b>")
    assert got == "<b>say &quot;hi&quot;</b>", f"safe_html gave {got!r}"


def test_format_spec():
    """The format spec is applied before escaping"""
    total = 1234.5
    got = safe_html(t"<td>{total:,.2f}</td>")
    assert got == "<td>1,234.50</td>", f"safe_html gave {got!r}"


def test_rejects_str():
    """A plain str (an f-string result) raises TypeError"""
    name = "<b>Ada</b>"
    try:
        safe_html(f"<p>{name}</p>")
    except TypeError:
        pass
    else:
        assert False, "safe_html accepted a str; raise TypeError unless it is a Template"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Adding a str to a Template

name = "Ada"
greeting = t"Hi {name}" + "!"

What Python prints

TypeError: can only concatenate string.templatelib.Template (not "str") to string.templatelib.Template

Why, and the fix

Python cannot know whether "!" should be static text or a value, so Template + str is refused. Put the text inside the t-string, t"Hi {name}!", or wrap it: + Template("!") for static text, or + Template(Interpolation(value, "name")) for a value.

Treating a t-string as a str

name = "Ada"
print(t"Hi {name}".upper())

What Python prints

AttributeError: 'string.templatelib.Template' object has no attribute 'upper'

Why, and the fix

A Template has no string methods, because it is not text yet. Render it first, with your own renderer, and call str methods on the result, or change the values before they go into the t-string.

Passing a custom format spec to format()

name = "ada"
tpl = t"{name:upper}"
part = tpl.interpolations[0]
print(format(part.value, part.format_spec))

What Python prints

ValueError: Invalid format specifier 'upper' for object of type 'str'

Why, and the fix

A t-string accepts any text after the colon, because the renderer decides what it means. format() only understands the standard mini-language. If your renderer invents specs such as upper, handle them itself before falling back to format().

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

A t-string is a Template, not a str

t"Hello {name}!" has the syntax of an f-string, but it evaluates to a string.templatelib.Template, and nothing is joined yet. template.strings holds the static parts, ("Hello ", "!"), and always has one more item than template.interpolations; empty strings fill the gaps, so t"{a}{b}".strings is ("", "", ""). template.values holds the evaluated values. Iterating a Template gives its non-empty strings and Interpolation objects in order. A Template has no str methods, and template + "text" raises TypeError: the docs leave it open whether "text" would be static or a value.

Interpolation: value, expression, conversion, format_spec

Each Interpolation stores the evaluated value, the expression text ("count * 2"), the conversion ("r", "s", "a" or None) and the format_spec (".2f", or "" when none is given). Unlike an f-string, a t-string applies neither the conversion nor the format spec: the code that processes the Template decides. To mimic an f-string, call convert(value, conversion) from string.templatelib, then format(result, format_spec). A renderer may also treat the spec as its own mini-language, because format_spec can be any string, but then it must not pass an unknown spec to format().

Why this matters for untrusted input

An f-string runs at once and returns one str. A function that receives f"<p>{name}</p>" cannot tell the markup the programmer wrote from the text the user typed, so it cannot escape only the user part. A Template keeps them apart until render time: the renderer escapes each interpolation (html.escape for HTML) and copies the static strings unchanged. The same idea serves SQL (turn each value into a ? placeholder, as lesson A5.3 will show), shell commands and structured logs. A good renderer refuses a plain str, so a caller cannot slip past it with an f-string.

Sources

Last reviewed September 29, 2026