Template Strings (t-strings)
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..stringsis the tuple of static chunks;.interpolationsholdsInterpolationobjects;.valuesis the tuple of evaluated results. There is always one more string than interpolations — the holes sit between the chunks.- Iterating a
Templateyields non-empty static strings andInterpolations in order. Empty static pieces are skipped by iteration but still appear in.strings. - Each
Interpolationhas.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.convertmimics f-string conversion when you want that. Template + stris aTypeError. ConcatenateTemplatewithTemplate, or wrap a static piece inTemplate("...").- 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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
<p>table 7: <script>alert("x")</script></p>
<p>table 7: '<script>alert("x")</script>'</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.pyOutput:
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.pyOutput:
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 withisinstance(..., Interpolation). - Apply
convertandformatonly when that sink should behave like an f-string. - Never concatenate
Templatewithstr. Never shipstr(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
- In
t_vs_f.py, printtmsg + t" ok"andlist(tmsg + t" ok"). Confirm you can concatenate two templates. - In
t_html.py, changeguesttoO'Reilly & Sonsand re-run. Confirm quotes and&are escaped. - In
t_sql.py, addAND seats >= {seats}withseats = 2. Confirm a third?and2inparams. - In
t_render.py, drop theconvert/formatcalls and appendstr(part.value)only. Re-run the!rline — then putconvertback and explain the difference in one sentence.