Python’s f-strings are excellent when the desired result is immediately a string. That same immediacy becomes a limitation when an application needs to inspect interpolated values before deciding how they should be represented.
Python 3.14 adds template string literals, usually called t-strings, for that boundary. A t-string looks much like an f-string, but it does not immediately collapse its literal text and interpolated values into one str. Instead, it produces a structured Template object from string.templatelib.
That distinction makes t-strings useful for APIs that need policy between interpolation and rendering. An HTML renderer can escape dynamic values. A query builder can reject unsupported values instead of concatenating them into SQL. A logging system can preserve fields as structured data. A shell-command API can refuse to treat interpolated strings as syntax.
T-strings do not automatically make any of those operations safe. They provide a representation on which a processor can implement the right rules.
A t-string is not a string
Consider an f-string:
name = "Ada"
message = f"Hello, {name}!"
print(type(message))
# <class 'str'>By the time message exists, the boundary between literal text and the value of name has disappeared.
Change the prefix from f to t in Python 3.14:
name = "Ada"
template = t"Hello, {name}!"
print(type(template))
# <class 'string.templatelib.Template'>The template retains its static and dynamic pieces separately:
print(template.strings)
# ('Hello, ', '!')
print(template.values)
# ('Ada',)
print(template.interpolations)
# (Interpolation('Ada', 'name', None, ''),)For a template created by literal syntax, the expression inside {...} is still evaluated when the t-string expression executes. T-strings are therefore not a mechanism for delayed evaluation of arbitrary Python source. What they delay is the decision about how the resulting value should be converted and combined with surrounding text.
That is the key mental model:
f-string: expression -> evaluate -> format -> str
t-string: expression -> evaluate -> structured Template -> your processorThe processor defines the semantics
A Template is useful only when some code consumes it. A deliberately simple renderer can demonstrate the idea:
from string.templatelib import Interpolation, Template
def render_text(template: Template) -> str:
output: list[str] = []
for part in template:
if isinstance(part, str):
output.append(part)
elif isinstance(part, Interpolation):
output.append(str(part.value))
else:
raise TypeError(f"unexpected template part: {type(part)!r}")
return "".join(output)Now a caller can write:
count = 3
print(render_text(t"Found {count} records"))
# Found 3 recordsThis processor intentionally behaves like basic string interpolation. More interesting processors can apply domain-specific rules to every Interpolation before producing an output.
That is where t-strings differ from simply inventing another formatting syntax: normal Python expressions can appear in the interpolation, while the resulting value remains identifiable as dynamic data.
Escape according to the output context
Suppose an application generates a small amount of HTML. Direct interpolation can mix data with markup:
username = '<img src=x onerror="alert(1)">'
html = f"<p>Hello, {username}</p>"If that output is interpreted as HTML, treating untrusted text as markup is dangerous.
A t-string processor can make escaping the default for dynamic values:
from html import escape
from string.templatelib import Interpolation, Template
def render_html(template: Template) -> str:
output: list[str] = []
for part in template:
if isinstance(part, str):
output.append(part)
elif isinstance(part, Interpolation):
output.append(escape(str(part.value), quote=True))
else:
raise TypeError("unsupported template component")
return "".join(output)Then:
username = '<img src=x onerror="alert(1)">'
page = render_html(t"<p>Hello, {username}</p>")The important security property is not the t prefix itself. It is that render_html() can distinguish literal template text from interpolated data and enforce a rule for the latter.
Even this example is not a complete HTML templating engine. Escaping rules depend on context. Text inside an HTML element, an attribute, a URL, CSS, and JavaScript do not all have identical safety requirements. A production renderer must model the contexts it supports rather than applying one escaping function everywhere and claiming universal safety.
Do not turn t-strings into SQL concatenation
Structured interpolation is especially tempting for database APIs. The wrong implementation would simply stringify every value:
# Do not use this design for SQL.
def unsafe_sql(template):
return "".join(
part if isinstance(part, str) else str(part.value)
for part in template
)That recreates the same injection problem as ordinary string concatenation.
A better SQL-oriented processor would keep literal SQL separate from parameters and hand values to the database driver through its parameter-binding mechanism. Conceptually:
query = t"SELECT * FROM users WHERE email = {email}"
sql, parameters = compile_query(query)
cursor.execute(sql, parameters)For a driver using ? placeholders, a minimal compiler could look like this:
from string.templatelib import Interpolation, Template
def compile_query(template: Template) -> tuple[str, tuple[object, ...]]:
sql: list[str] = []
parameters: list[object] = []
for part in template:
if isinstance(part, str):
sql.append(part)
elif isinstance(part, Interpolation):
sql.append("?")
parameters.append(part.value)
else:
raise TypeError("unsupported template component")
return "".join(sql), tuple(parameters)This small example illustrates the architectural advantage: the interpolated value never has to become SQL source code.
A real query builder needs more policy. Table names, column names, sort directions, operators, and SQL fragments are syntax rather than ordinary bound values. They should not be accepted through the same path as data parameters. Usually they require a restricted identifier type, an allowlist, or explicit query-builder primitives.
Interpolations contain more than values
Each Interpolation exposes several pieces of information:
amount = 12.345
item = t"Total: {amount:.2f}"
interpolation = item.interpolations[0]
print(interpolation.value) # 12.345
print(interpolation.expression) # amount
print(interpolation.conversion) # None
print(interpolation.format_spec) # .2fThis is deliberately different from f-strings. With an f-string, formatting has already happened when the final string is produced. With a t-string, the processor gets the value and the requested formatting information separately.
That means a processor can choose to honor a format specification, reinterpret it for its own domain, or reject it.
For example, a strict renderer can explicitly support Python’s normal formatting protocol:
from string.templatelib import Interpolation, Template, convert
def render_formatted(template: Template) -> str:
result: list[str] = []
for part in template:
if isinstance(part, str):
result.append(part)
continue
if not isinstance(part, Interpolation):
raise TypeError("unexpected template component")
value = part.value
if part.conversion is not None:
value = convert(value, part.conversion)
result.append(format(value, part.format_spec))
return "".join(result)The explicitness matters. T-string conversions and format specifications are metadata for the processor; they are not automatically applied as though the expression were an f-string.
Format specifications can be domain-specific
Because the processor controls interpretation, a format specification does not have to mean exactly what format() means.
Imagine a logging library that accepts specifications such as secret, json, or id:
request_id = "req_123"
token = "super-secret"
record = t"request={request_id:id} token={token:secret}"Its renderer could hash an identifier, redact a secret, and reject every unknown specification. That is often clearer than relying on wrapper objects whose __format__() methods quietly encode application policy.
There is an important consequence: a generic t-string consumer should not assume that every format_spec is valid input to Python’s built-in format(). The API consuming the template owns the contract.
Nested expressions in a format specification are evaluated when the t-string is created. For example:
precision = 3
value = 1.23456
template = t"{value:.{precision}f}"
print(template.interpolations[0].format_spec)
# .3fThe processor receives the resulting specification rather than an unevaluated {precision} expression.
Conversions are also processor-controlled
T-string syntax supports familiar conversion markers such as !s, !r, and !a:
value = "hello"
template = t"value={value!r}"
print(template.interpolations[0].conversion)
# rThe conversion is retained on the Interpolation. It is not automatically applied to value.
This is useful for APIs that want to prohibit conversions. An HTML renderer, for example, might decide that !r has no meaningful public contract and reject it rather than accidentally exposing representation details.
if interpolation.conversion is not None:
raise ValueError("conversions are not supported here")Failing closed is often preferable for security-sensitive processors. If syntax has no defined meaning in the domain, reject it instead of guessing.
Expression text is metadata, not a variable name contract
An interpolation also records the source expression text:
user = {"name": "Ada"}
template = t"Hello {user['name']}"
print(template.interpolations[0].expression)
# user['name']That text can be useful for diagnostics, tracing, or tooling. It should not be confused with a trustworthy identifier.
A processor generally already has the evaluated value. Re-evaluating expression with eval() would add risk and can produce different results if program state has changed. Treat the expression primarily as descriptive metadata unless the API has a carefully designed reason to do otherwise.
The same caution applies to authorization. The expression text tells you how the caller wrote an interpolation, not whether the resulting value is permitted to enter a particular sink.
Template iteration has a subtle empty-string rule
A Template exposes aligned tuples of static strings and interpolations. Its strings tuple always contains one more element than its interpolations tuple.
Adjacent interpolations therefore have an empty static string between them:
first = "A"
second = "B"
template = t"{first}{second}"
print(template.strings)
# ('', '', '')Iteration behaves slightly differently: empty strings are omitted.
print(list(template))
# [Interpolation(...), Interpolation(...)]This distinction matters when implementing a processor whose algorithm depends on exact positional boundaries. If empty literal segments are semantically meaningful to your application, work with strings and interpolations directly rather than assuming iteration reproduces every tuple entry.
For ordinary renderers that simply process components in order, iteration is often convenient.
Template concatenation keeps the boundary explicit
Templates can be concatenated with other templates:
name = "Ada"
left = t"Hello, "
right = t"{name}!"
combined = left + rightBut directly adding a Template and a plain str is not supported. The ambiguity is meaningful: should that string be trusted literal template text, or should it be treated as dynamic data?
Code constructing templates programmatically should answer that question explicitly using Template and Interpolation rather than allowing an accidental conversion.
This is a useful design lesson for application APIs too. Security boundaries are easier to audit when trusted syntax and untrusted data have different types or constructors.
T-strings are not string.Template
Python already has a class named string.Template, which performs $name-style substitution. That older facility and Python 3.14 t-strings solve different problems.
T-string literals evaluate to string.templatelib.Template objects. They support Python expression syntax inside interpolations and expose the result as structured components for custom processing.
The older string.Template accepts a template string and later substitutes named placeholders from a mapping or keyword arguments. Existing code using it does not become a t-string processor simply because both APIs use the word “template.”
When documenting an API, names such as “t-string”, “template string literal”, and string.templatelib.Template help avoid this ambiguity.
Evaluation still happens before processing
A t-string processor can control rendering, but it cannot prevent side effects that already occurred while evaluating interpolation expressions.
def load_user():
print("loading")
return "Ada"
message = t"Hello {load_user()}"
# "loading" has already been printed here.This property is important when designing APIs around untrusted input. T-strings do not parse user-provided text into executable interpolation expressions. The expressions are Python source written by the programmer and evaluated normally.
Do not build a system that accepts arbitrary user text and evaluates it as Python merely to turn it into a t-string. That would be a separate code-execution problem that structured rendering does not solve.
Validate types at the boundary
A domain-specific processor should define which interpolation types it accepts.
For example, a command builder might accept only path-like objects and explicit option types. A metrics API might accept strings, integers, and finite floating-point values. A URL builder might distinguish path segments from complete URLs.
A strict processor can enforce those constraints centrally:
ALLOWED = (str, int)
if not isinstance(interpolation.value, ALLOWED):
raise TypeError(
f"unsupported interpolation type: "
f"{type(interpolation.value).__name__}"
)Be precise with Python’s type relationships when the distinction matters. bool is a subclass of int, so isinstance(True, int) is true. If a protocol accepts integers but not booleans, test that rule explicitly.
Validation should also cover length, numeric ranges, Unicode policy, and any domain-specific invariants needed before a value reaches the final sink.
Preserve structure as long as it is useful
The biggest mistake when adopting t-strings is to immediately render every template into text at the first function boundary.
Suppose an application has this pipeline:
application -> logger -> JSON encoder -> outputIf the logger converts a t-string to str immediately, downstream code can no longer distinguish fields from literal text. Instead, the logger could inspect interpolations, apply redaction rules, attach structured attributes, and render human-readable text only at the final presentation boundary.
The same principle applies to database adapters, telemetry systems, markup generation, command construction, and internationalization libraries. Keep data structured until the component with enough context can make the correct rendering decision.
Test processors, not just happy-path syntax
A t-string processor is a parser-like boundary and deserves adversarial tests.
Useful cases include:
- a template with no interpolations;
- a template containing only an interpolation;
- adjacent interpolations with empty literal segments;
- values containing quotes, angle brackets, newlines, NUL characters, or delimiter-like text;
- unsupported value types;
- conversions the processor does not recognize;
- empty and unusual format specifications;
- very large values and templates;
- exceptions raised while converting or validating values;
- values whose
__str__(),__repr__(), or__format__()methods have side effects or raise exceptions.
For a security-sensitive renderer, test the property you actually need. An SQL compiler should verify that interpolated data remains in the parameter collection rather than appearing in query text. An HTML processor should verify context-appropriate escaping. A logging processor should verify that secrets are not present in rendered output or fallback error messages.
Tests should also pin the supported Python version. T-string literals are syntax introduced in Python 3.14, so a file containing them cannot simply be imported by an older Python interpreter and conditionally ignored at runtime.
Choose t-strings when a consumer needs structure
F-strings remain the simpler tool when immediate formatting is exactly what you want:
name = "Ada"
print(f"Hello, {name}!")T-strings become compelling when the receiving API needs to distinguish authored literal text from interpolated values:
render_html(t"<p>{comment}</p>")
execute_query(t"SELECT * FROM users WHERE id = {user_id}")
log_event(t"user={user_id} token={token:secret}")Those examples are only safe if their processors are correctly designed. The t-string itself supplies structure, not sanitization, parameterization, redaction, or authorization.
That separation is the feature. Python 3.14 gives libraries a native interpolation syntax without forcing them to accept the rendering semantics of f-strings. Application code can remain concise while the receiving API decides how values cross a security or formatting boundary.
When that boundary matters, keeping interpolation structured for a little longer can be much more valuable than producing a string as quickly as possible.