skip to content

What do Python 3.14 t-strings change about interpolating untrusted values?

level: middleimportance: nice to knowfreq 14%

answer

  1. The join is postponed on purpose
  2. It does not evaluate to a str
  3. Literal parts and values stay distinguishable
  4. The renderer chooses the escaping policy
  5. string.templatelib.Template, new in 3.14

basics

~20 s

A t-string literal evaluates to a string.templatelib.Template rather than a str: the literal chunks and the interpolated values stay separate until a renderer joins them, so the renderer can escape each value for its destination first.

solid answer

~50 s

PEP 750 added template strings in **3.14**. `t"Hello {name}"` produces a `string.templatelib.Template` holding the literal parts in `strings` and the interpolated parts as `Interpolation` objects carrying the value, the source expression text, the conversion and the format spec. Iterating the template yields those pieces in order. Nothing renders by default — `str()` on one gives a debug representation, not interpolated text — so a function that wants a finished string must walk the parts, and that walk is exactly where it can escape values while leaving literals alone. The property being preserved is *provenance*: an f-string destroys the seam between what the developer wrote and what the user supplied, which is why escaping otherwise has to happen by hand at every call site. It does **not** make attacker-supplied templates safe: a t-string literal is source code, like an f-string.

code

pycon · 8 lines
pycon
>>> name = "ada"
>>> tmpl = t"Hello {name!r:>8}"
>>> tmpl.strings
('Hello ', '')
>>> tmpl.values
('ada',)
>>> str(tmpl)
"Template(strings=('Hello ', ''), interpolations=(Interpolation('ada', 'name', 'r', '>8'),))"

go deeper

for a junior

Recall the shape: a t prefix produces a Template object, not a string, and something must render it before you have text. Know that it is new in Python 3.14 and cannot be used on older versions.

for a middle

Explain what is preserved — literal chunks in strings, values wrapped in Interpolation objects — and why keeping the seam lets a renderer escape only the user-supplied parts. Be clear that no escaping happens on its own.

for a senior

Argue where it belongs: a library API that accepts a Template refuses pre-joined input at the type level, the way a parameterised interface refuses a fully-formed statement. Note that ecosystem support is early and that adopting it sets a 3.14 floor.

for a principal

Weigh a 3.14 version floor and thin ecosystem support against a per-sink escaping contract you could otherwise only ask for in a docstring, and decide whether new internal interfaces adopt it now or wait a release or two.

### What a t-string evaluates to Python 3.14 added **template strings** (PEP 750). Written with a `t` prefix, `t"Hello {name}"` looks like an f-string but does not produce a `str`. It evaluates to a `string.templatelib.Template`: an object that keeps the literal chunks and the interpolated values apart. ```python from string.templatelib import Template, Interpolation name = "ada" tmpl = t"Hello {name!r:>8}" tmpl.strings # ('Hello ', '') tmpl.values # ('ada',) tmpl.interpolations # (Interpolation('ada', 'name', 'r', '>8'),) ``` Iterating a `Template` yields those parts in order — `str` chunks and `Interpolation` objects alternating. Each `Interpolation` carries the evaluated `value`, the source text of the expression that produced it, the conversion (`!r`, `!s`, `!a`) and the format spec. Templates concatenate with `+`, and an f-string-style spec is preserved rather than applied. ### Why the separation is the security property The bug class t-strings attack is not template injection; it is **losing track of provenance**. By the time an f-string has produced a `str`, the literal parts and the interpolated user data are indistinguishable — a sink that receives it (HTML, a shell command line, a query string, a log record) cannot tell which characters the developer wrote and which the attacker did, so it cannot escape only the second kind. Escaping therefore has to happen at every call site, by hand, and the one site that forgets is the vulnerability. A `Template` postpones the join. The renderer sees the seam, so it can escape values and leave literals alone — one policy, in one place, applied to every interpolation by construction: ```python import html from string.templatelib import Interpolation, Template def render(template: Template) -> str: parts = [] for part in template: parts.append(html.escape(str(part.value)) if isinstance(part, Interpolation) else part) return "".join(parts) comment = "<script>alert(1)</script>" render(t"<p>{comment}</p>") # '<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>' ``` ### Nothing renders by default, on purpose There is no automatic conversion to `str`. Calling `str()` on a `Template` gives a debug representation — `Template(strings=..., interpolations=...)` — not interpolated text, so a `Template` handed to a function that expected a finished string produces visible nonsense or a type error rather than a silently unescaped payload. The design deliberately declines to pick a rendering, because the correct one depends on the destination: HTML escaping, shell quoting and query-parameter binding are different answers to the same question, and a library that accepts a `Template` gets to give its own. ### What t-strings do *not* do They are compile-time syntax, exactly like f-strings. The literal parts come from your source file, so a template that arrives at runtime — in a request body, a settings row, a translation table — is still an ordinary `str`, and rendering it still means `str.format` and still means the attribute-walk problem. A candidate who offers t-strings as the fix for attacker-supplied templates has confused two different bugs. They also do not escape anything by themselves: a `Template` is inert until some renderer walks it, and a renderer that joins the parts without escaping is exactly as unsafe as the f-string it replaced. The value is that the escaping decision now has a single, checkable home. ### Where it matters in practice The realistic payoff is in library APIs. A function can declare that it takes a `Template` rather than a `str`, and thereby refuse pre-joined input at the type level: the caller cannot smuggle in a string built by concatenation, because the parameter simply is not a `str`. That is the same shape as a database driver refusing a fully-formed statement in favour of a statement plus parameters, and it is a considerably stronger contract than a docstring asking callers to escape first. Ecosystem support is early — 3.14 is the first release with the feature — so treat it as a direction of travel for new interfaces rather than a migration to run across an existing codebase. ### Version context `string.templatelib` and the `t` prefix are **new in Python 3.14**. On 3.13 and earlier they do not exist: the module import fails and the literal is a syntax error, so code using them cannot be backported by a shim. Asserting a t-string in a codebase that still supports 3.13 is a version-floor decision, not a style choice.

  • Does a t-string help when the template itself comes from a user?
    No, and this is the common confusion. A t-string is compile-time syntax: its literal parts come from your source file, exactly like an f-string's. A template that arrives at runtime is an ordinary `str`, and rendering it still means `str.format` and still exposes the attribute and item walk in its field names. T-strings address how a value reaches a sink, not who authored the template.
  • Why doesn't calling str() on a Template return the interpolated text?
    Because there is no single correct rendering. HTML escaping, shell quoting and query binding are different answers for different destinations, so the design declines to pick one; `str()` yields a debug representation instead. The practical benefit is that a Template handed to code expecting a finished string produces obvious nonsense or a type error rather than a silently unescaped payload.
  • What does an Interpolation carry besides the value?
    The source text of the expression that produced it, the conversion character (`!r`, `!s`, `!a`) and the format spec, all unapplied. A renderer can therefore honour the requested formatting after escaping, and can report which expression produced a rejected value — useful for diagnostics, since the expression text is developer-authored and safe to log while the value may not be.

An f-string is a finished sentence; a t-string is the sentence still in pieces, with the quoted words kept in quotation marks. Whoever assembles it can still tell which words came from outside and treat them accordingly.

saying these in an interview costs you the question

  • Thinks a t-string escapes interpolated values automatically
  • Says t-strings make user-supplied templates safe to render
  • Believes template strings shipped before Python 3.14
  • Treats Template as a subclass of str
  • Assumes str() on a Template returns the rendered text

context