Template Strings (t-strings)

Updated

October 31, 2026

Template Strings (t-strings)

An f-string builds a finished str the moment it runs. A t-string (Python 3.14, PEP 750) looks the same — braces, !r, format specs — but the t prefix returns a string.templatelib.Template: the static pieces and the interpolated values stay separate until your code decides how to combine them. The boring default is still an f-string for ordinary messages. Reach for a t-string when the values must be escaped, parameterized, or logged as structured parts — not glued into a string first.

Mental model

  • f"..." → str. Done. Conversions (!s, !r, !a) and format specs (:.2f) are applied for you.
  • t"..." → Template. Not a string. .strings is the tuple of static chunks; .interpolations holds Interpolation objects; .values is the tuple of evaluated results. There is always one more string than interpolations — the holes sit between the chunks.
  • Iterating a Template yields non-empty static strings and Interpolations in order. Empty static pieces are skipped by iteration but still appear in .strings.
  • Each Interpolation has .value, .expression (source text), .conversion (None, "s", "r", or "a"), and .format_spec. Unlike f-strings, you choose whether to apply conversion and format — string.templatelib.convert mimics f-string conversion when you want that.
  • Template + str is a TypeError. Concatenate Template with Template, or wrap a static piece in Template("...").
  • Stdlib types live in string.templatelib. There is no built-in HTML or SQL renderer: write a small function that walks the parts.

Worked examples

Case 1: Same braces, different type

Save as t_vs_f.py. One desk table, two prefixes. The f-string is text you can print and concatenate. The t-string is a structure.

# t_vs_f.py
def main():
    table = 12
    fmsg = f"table {table}"
    tmsg = t"table {table}"
    print(type(fmsg).__name__, fmsg)
    print(type(tmsg).__name__, tmsg.strings, tmsg.values)


if __name__ == "__main__":
    main()

Run:

uv run python t_vs_f.py

Output:

str table 12
Template ('table ', '') (12,)

tmsg is not "table 12". Its .values hold 12 as an int, not as digits inside a string.

Case 2: Walk the parts at the desk

Save as t_inspect.py. Print static chunks and each interpolation’s expression, value, conversion, and format spec.

# t_inspect.py
from string.templatelib import Interpolation


def main():
    table = 12
    note = "no onions"
    tmpl = t"ticket → table {table}; note={note!r}"
    print(type(tmpl).__name__)
    print(tmpl.strings)
    print(tmpl.values)
    for part in tmpl:
        if isinstance(part, Interpolation):
            print(
                "interp",
                part.expression,
                part.value,
                part.conversion,
                repr(part.format_spec),
            )
        else:
            print("static", repr(part))


if __name__ == "__main__":
    main()

Run:

uv run python t_inspect.py

Output:

Template
('ticket → table ', '; note=', '')
(12, 'no onions')
static 'ticket → table '
interp table 12 None ''
static '; note='
interp note no onions r ''

!r did not turn the note into a quoted string yet. It only set .conversion to "r". A processor that cares will call convert(part.value, part.conversion).

Case 3: Render like an f-string (on purpose)

Save as t_render.py. When you want the familiar finished text, apply convert and format yourself. This is the boring “I still need a string” path — and it makes the deferred steps visible.

# t_render.py
from string.templatelib import Interpolation, convert


def render(template):
    parts = []
    for part in template:
        if isinstance(part, Interpolation):
            text = convert(part.value, part.conversion)
            if part.format_spec:
                text = format(text, part.format_spec)
            else:
                text = str(text)
            parts.append(text)
        else:
            parts.append(part)
    return "".join(parts)


def main():
    cents = 1850
    note = "rush"
    print(render(t"total ${cents / 100:.2f}"))
    print(render(t"note={note!r}"))


if __name__ == "__main__":
    main()

Run:

uv run python t_render.py

Output:

total $18.50
note='rush'

Same result as the matching f-strings. Prefer f"..." when this is all you need. Prefer t"..." when Case 4 or Case 5 is the real goal.

Case 4: Escape guest text for HTML

Save as t_html.py. Static markup stays raw. Interpolated values pass through html.escape. That split is why t-strings exist: you never build "<p>" + guest + "</p>" with a hostile guest.

# t_html.py
from html import escape
from string.templatelib import Interpolation, convert


def safe_html(template):
    parts = []
    for part in template:
        if isinstance(part, Interpolation):
            text = convert(part.value, part.conversion)
            if part.format_spec:
                text = format(text, part.format_spec)
            parts.append(escape(str(text), quote=True))
        else:
            parts.append(part)
    return "".join(parts)


def main():
    guest = '<script>alert("x")</script>'
    table = 7
    print(safe_html(t"<p>table {table}: {guest}</p>"))
    print(safe_html(t"<p>table {table}: {guest!r}</p>"))


if __name__ == "__main__":
    main()

Run:

uv run python t_html.py

Output:

<p>table 7: &lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;</p>
<p>table 7: &#x27;&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&#x27;</p>

The tags in the static pieces are real HTML. The script in guest is text. An f-string of the same shape would have injected markup.

Case 5: SQL placeholders, not string glue

Save as t_sql.py. Walk the template: static SQL stays; each interpolation becomes ? and its value goes in a params tuple. The database API binds params — you never splice user data into the statement string.

# t_sql.py
from string.templatelib import Interpolation


def sql_query(template):
    chunks = []
    params = []
    for part in template:
        if isinstance(part, Interpolation):
            chunks.append("?")
            params.append(part.value)
        else:
            chunks.append(part)
    return "".join(chunks), tuple(params)


def main():
    table = 12
    status = "open"
    sql, params = sql_query(
        t"SELECT id FROM tickets WHERE table_n = {table} AND status = {status}"
    )
    print(sql)
    print(params)


if __name__ == "__main__":
    main()

Run:

uv run python t_sql.py

Output:

SELECT id FROM tickets WHERE table_n = ? AND status = ?
(12, 'open')

An f-string here would be the classic injection foot-gun. The t-string keeps values out of the SQL text until a real driver binds them.

The trap

Save as t_trap.py. Treating a Template like a str fails loudly — or, worse, str(template) gives a debug dump you might accidentally ship.

# t_trap.py
def main():
    table = 12
    tmpl = t"table {table}"
    try:
        print("msg: " + tmpl)
    except TypeError as exc:
        print(type(exc).__name__ + ":", exc)
    print("forced:", "msg: " + str(tmpl))
    print("parts:", list(tmpl))


if __name__ == "__main__":
    main()

Run:

uv run python t_trap.py

Output:

TypeError: can only concatenate str (not "string.templatelib.Template") to str
forced: msg: Template(strings=('table ', ''), interpolations=(Interpolation(12, 'table', None, ''),))
parts: ['table ', Interpolation(12, 'table', None, '')]

+ with a plain string is rejected. str(tmpl) is not a render — it is the object’s representation. Process the template (Cases 3–5) or keep it as a Template. Do not str() your way out of the type error and call it done.

The boring rule

  • Use f-strings for human messages, logs, and errors when every value is already safe to embed.
  • Use t-strings when interpolations need escaping, parameterization, or structured handling before they become text.
  • Write one small processor per sink (safe_html, sql_query, render) that walks parts with isinstance(..., Interpolation).
  • Apply convert and format only when that sink should behave like an f-string.
  • Never concatenate Template with str. Never ship str(template) as user-facing text.
  • Do not replace every f-string with a t-string. Deferred structure is a tool, not a fashion.

Try this

  1. In t_vs_f.py, print tmsg + t" ok" and list(tmsg + t" ok"). Confirm you can concatenate two templates.
  2. In t_html.py, change guest to O'Reilly & Sons and re-run. Confirm quotes and & are escaped.
  3. In t_sql.py, add AND seats >= {seats} with seats = 2. Confirm a third ? and 2 in params.
  4. In t_render.py, drop the convert / format calls and append str(part.value) only. Re-run the !r line — then put convert back and explain the difference in one sentence.