Safe SQL & HTML with Python 3.14 t-strings (PEP 750)
Safe SQL & HTML with Python 3.14 t-strings (PEP 750) — this guide is about t"..." literals that evaluate to string.templatelib.Template, not finished strings. You inspect static parts and interpolations, then write processors that emit parameterized SQL or escaped HTML.
PyInns already covers related-but-different tools: f-string improvements (Python 3.15) and the classic string.Template ($placeholders). For everyday formatting without safety processors, see format() and modern f-strings. Those posts stay useful; this one is exclusively PEP 750.
TL;DR
t"Hello {name}"→Template, never astr- Inspect
.strings,.interpolations,.values; each hole is anInterpolation - SQL processor: join static SQL + placeholders; return
(sql, params)— never splice raw values into the SQL text - HTML processor: escape interpolation values (e.g.
html.escape); leave static markup alone - Requires Python 3.14+ (syntax; no
__future__backport)
Three “templates” — open with the contrast
| Feature | What you write | What you get | Best for |
|---|---|---|---|
| f-strings | f"Hi {name}" |
immediate str |
logs, display, trusted formatting |
string.Template |
Template("Hi $name") |
str after .substitute() |
external/$placeholder templates |
| t-strings (PEP 750) | t"Hi {name}" |
string.templatelib.Template |
custom processors (SQL, HTML, DSLs) |
f-strings combine everything eagerly — great for readability, dangerous if you embed user input into SQL or HTML. string.Template delays substitution but still produces a plain string with values already inlined. t-strings keep static text and evaluated values separate so your function decides how (or whether) to combine them.
Requirements
python3.14 -c "import string.templatelib; print(string.templatelib.Template)"
# SyntaxError on 3.13 and earlier — t-strings are not backportable
Official API: string.templatelib and PEP 750.
t"" → Template: strings, interpolations, values
A t-string looks like an f-string but the prefix is t. Expressions inside {...} are evaluated eagerly in the current scope (same as f-strings). Conversions and format specs are stored as metadata — processors apply them if they choose.
from string.templatelib import Template, Interpolation
user = "Camembert"
qty = 3
tmpl = t"Order {qty} of {user!s}."
assert isinstance(tmpl, Template)
assert tmpl.strings == ("Order ", " of ", ".")
assert tmpl.values == (3, "Camembert")
# One more string than interpolations; holes sit between the strings:
# strings: ("Order ", " of ", ".")
# values: ( 3, "Camembert", )
for part in tmpl.interpolations:
assert isinstance(part, Interpolation)
print(part.value, part.expression, part.conversion, part.format_spec)
# 3 qty None ''
# Camembert user s ''
Empty static chunks appear when interpolations are adjacent (t"{a}{b}" → strings == ("", "", "")). Iteration over a Template yields non-empty strings and Interpolation objects in order — empty strings are omitted from iter(template).
Interpolation fields (official shape)
value— evaluated result of the expressionexpression— source text inside the braces (before!,:, or=)conversion—'a'/'r'/'s'orNone(unlike f-strings, not applied automatically)format_spec— format string (again: metadata until your processor uses it)
from string.templatelib import convert
price = 12.5
item = t"total={price:.2f}"
interp = item.interpolations[0]
assert interp.expression == "price"
assert interp.format_spec == ".2f"
assert interp.conversion is None
# Mimic f-string conversion when you want it:
assert convert(42, "r") == "42" # repr
assert convert("x", "s") == "x" # str
assert convert("x", None) == "x" # unchanged
Pattern matching works well when writing processors:
from string.templatelib import Template, Interpolation
def describe(template: Template) -> list[str]:
out: list[str] = []
for item in template:
match item:
case str() as s:
out.append(f"STATIC:{s!r}")
case Interpolation(value, expression, conversion, format_spec):
out.append(
f"DYN:{expression}={value!r} "
f"conv={conversion} spec={format_spec!r}"
)
return out
print(describe(t"id={42:04d}"))
# ['STATIC:\'id=\'', "DYN:42=42 conv=None spec='04d'"]
Processor 1 — parameterized SQL (placeholders + params)
Never interpolate raw values into the SQL string. Emit a static SQL skeleton with placeholders and a parallel params list for the DB-API. That is the entire point of t-strings for SQL.
from __future__ import annotations
from string.templatelib import Template, Interpolation, convert
from typing import Any
def sql(template: Template, *, placeholder: str = "?") -> tuple[str, list[Any]]:
"""Build (sql_text, params) for DB-API execute().
Static parts are concatenated as-is.
Each Interpolation becomes one placeholder; its value goes into params.
Values are NEVER spliced into the SQL string.
"""
if not isinstance(template, Template):
raise TypeError("sql() expects a t-string Template")
parts: list[str] = []
params: list[Any] = []
# Walk strings/interpolations in lockstep (official layout)
for i, static in enumerate(template.strings):
parts.append(static)
if i < len(template.interpolations):
interp = template.interpolations[i]
value = convert(interp.value, interp.conversion)
if interp.format_spec:
# Formatting for display only — still bind the formatted
# string as a parameter, never as SQL text.
value = format(value, interp.format_spec)
parts.append(placeholder)
params.append(value)
return "".join(parts), params
# --- usage ---
user_id = 42
# Hostile input: classic injection payload
name = "Robert'); DROP TABLE students;--"
query, params = sql(
t"SELECT id, email FROM users WHERE id = {user_id} AND name = {name}"
)
assert query == "SELECT id, email FROM users WHERE id = ? AND name = ?"
assert params == [42, "Robert'); DROP TABLE students;--"]
# Hand to the driver (illustrative):
# cursor.execute(query, params)
print(query)
print(params)
Swap placeholder="%" or named-placeholder builders as needed for your driver — the invariant is the same: static SQL from .strings, values only in the params sequence. Named placeholders are a small extension: use interp.expression (sanitized) as the key, still never paste value into the SQL text.
def sql_named(template: Template) -> tuple[str, dict[str, Any]]:
parts: list[str] = []
params: dict[str, Any] = {}
for i, static in enumerate(template.strings):
parts.append(static)
if i < len(template.interpolations):
interp = template.interpolations[i]
key = "".join(
ch if ch.isalnum() or ch == "_" else "_"
for ch in interp.expression
) or f"p{i}"
# Disambiguate repeated expressions
base = key
n = 1
while key in params:
n += 1
key = f"{base}_{n}"
value = convert(interp.value, interp.conversion)
if interp.format_spec:
value = format(value, interp.format_spec)
parts.append(f"%({key})s") # psycopg2 / MySQLdb style
params[key] = value
return "".join(parts), params
Processor 2 — HTML escaping of interpolations
Static markup stays trusted (you wrote it). Interpolation values are treated as untrusted text and escaped. That blocks XSS from user content while keeping tags you authored intact.
from __future__ import annotations
import html
from string.templatelib import Template, Interpolation, convert
def render_html(template: Template) -> str:
"""Escape dynamic parts; keep static HTML verbatim."""
if not isinstance(template, Template):
raise TypeError("render_html() expects a t-string Template")
chunks: list[str] = []
for item in template:
match item:
case str() as s:
chunks.append(s)
case Interpolation(value, _, conversion, format_spec):
value = convert(value, conversion)
if format_spec:
value = format(value, format_spec)
# Escape AFTER formatting so <, &, quotes cannot break out
chunks.append(html.escape(str(value), quote=True))
case _:
raise TypeError(f"unexpected template part: {item!r}")
return "".join(chunks)
evil = "<script>alert('xss')</script>"
title = "Cheese & Pickles"
page = render_html(t"<h1>{title}</h1><p>Comment: {evil}</p>")
assert "<script>" not in page
assert "<script>" in page
assert "Cheese & Pickles" in page
print(page)
# <h1>Cheese & Pickles</h1><p>Comment: <script>alert('xss')</script></p>
Mark trusted HTML fragments explicitly (e.g. a small wrapper type) if you need nested safe markup — do not silently skip escaping for plain strings. Nested t-strings that already returned escaped str can be passed through a sentinel so you do not double-escape.
from dataclasses import dataclass
@dataclass(frozen=True)
class SafeHTML:
html: str
def __str__(self) -> str:
return self.html
def render_html_safe(template: Template) -> str:
chunks: list[str] = []
for item in template:
match item:
case str() as s:
chunks.append(s)
case Interpolation(SafeHTML(html=_) as safe, *_):
# Already trusted HTML from a prior render_html call
chunks.append(str(safe))
case Interpolation(value, _, conversion, format_spec):
value = convert(value, conversion)
if format_spec:
value = format(value, format_spec)
chunks.append(html.escape(str(value), quote=True))
return "".join(chunks)
inner = SafeHTML(render_html(t"<em>{'nested'}</em>"))
outer = render_html_safe(t"<div>{inner}</div><span>{'<raw>'}</span>")
assert outer == "<div><em>nested</em></div><span><raw></span>"
Putting both together (mini app sketch)
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)")
conn.execute("INSERT INTO notes (body) VALUES (?)", ("hello <b>world</b>",))
note_id = 1
q, p = sql(t"SELECT body FROM notes WHERE id = {note_id}")
row = conn.execute(q, p).fetchone()
body = row[0]
# Safe HTML page: DB value escaped; static structure trusted
print(render_html(t"<article><p>{body}</p></article>"))
# <article><p>hello <b>world</b></p></article>
Practical rules (2026)
- Use f-strings for trusted display; use t-strings when a processor must intervene
- SQL: placeholders + params always; treat any “string-built SQL” with values as a bug
- HTML: escape interpolations by default; opt in to trusted HTML via an explicit type
- Respect
conversion/format_specwhen they affect presentation; still bind or escape the result - Do not confuse PEP 750 with
string.Templateor with f-string polish in 3.15
Wrap-up
Python 3.14 t-strings give you the f-string ergonomics you already know, plus a first-class Template so libraries can implement safe SQL and HTML without fragile regex or manual splitting. Start with the two processors above, keep static and dynamic parts separate, and link readers who need classic formatting to the f-string / format() / string.Template guides already on PyInns.