Skip to content

← Modern Syntax and Modernisation step 17 of 18

Hard End-to-End

Template strings (t-strings) and interpolation safety

An f-string hands the renderer a finished string. By then the information that mattered — which characters came from the developer and which came from a user — has been destroyed. That loss is the mechanism behind SQL injection and stored XSS, and no amount of escaping-at-the-call-site fixes it reliably, because the call site is exactly where people forget.

PEP 750 (Python 3.14) adds a second literal form:

name = "<script>alert(1)</script>"
template = t"<li>{name}</li>"

template is a string.templatelib.Template, not a str. It has:

  • .strings — the static chunks, always one longer than the interpolations;
  • .interpolations — an Interpolation per replacement field, carrying .value (the evaluated object), .expression (its source text), .conversion ("r", "s", "a" or None) and .format_spec;
  • iteration, which yields the two kinds interleaved in source order, skipping empty static chunks.

t-strings escape nothing by themselves. They are not a sanitiser. They are a structure-preserving literal: the renderer you write can finally see the seam.

What you are building

def render_html(template: Template) -> str
def sql(template: Template) -> tuple[str, list[object]]

render_html walks the template, emits static chunks verbatim, and HTML-escapes every interpolated value — after honouring its conversion and format spec, in that order.

sql walks the template, emits static chunks verbatim, replaces each interpolation with a literal ?, and collects the values into a parameter list. That is a parameterised query: the driver never sees user data as SQL text.

Then:

def solve(name: str, comment: str, min_age: int, score: float) -> dict[str, object]

which must build three templates as literals inside the function and return

  • "html"render_html(t"<li><b>{name}</b> said: {comment}</li>")
  • "detail"render_html(t"{name!r} scored {score:.2f}")
  • "sql" and "params" — from sql(t"SELECT * FROM users WHERE name = {name} AND age > {min_age}")

The asymmetry in the first one is the whole lesson: the <b> you typed is markup and survives; a <b> that arrives in name is data and comes out as &lt;b&gt;.

The type is the safety

Template is not a str subclass, and this is deliberate. Under mypy --strict, "prefix " + template is an error, f"{template}" gives you a Template repr rather than the text, and a function declared def query(sql: Template) cannot be handed an f-string by accident. The static gate now enforces at the boundary what a code review used to have to catch by eye.

Compare with the pre-3.14 tool for the same job, typing.LiteralString (PEP 675): def query(sql: LiteralString) rejects any string built from non-literal parts. That protects the static half — but it also rejects legitimate dynamic query construction. t-strings admit the dynamic parts and keep them labelled.

Version wall: t"..." is syntax. On 3.13 it is a SyntaxError at import, so a module using t-strings is a hard 3.14 dependency — declare it in requires-python, do not try to guard it at runtime.

Loading visualization…