Intermediate
You’ve probably used f-strings a hundred times. They’re fast, readable, and feel like magic — until someone passes <script>alert('xss')</script> as a username and your beautifully formatted HTML becomes a security incident. F-strings are eager: they evaluate and produce a plain string immediately, with no way to inspect, sanitize, or transform the interpolated values before they become part of the output. That limitation is baked into their design, and for years Python developers have worked around it with template engines, manual escaping, and parameterized queries.
Python 3.14 introduces T-strings (PEP 750), a new kind of string literal that looks almost identical to an f-string but behaves fundamentally differently. Instead of evaluating to a str, a T-string evaluates to a Template object that carries the static string parts and the interpolated values separately, before they’re combined. That separation is the whole point — it gives you, or a library, the chance to inspect, transform, and sanitize every interpolated value before it ever touches the output string.
In this article we’ll cover what T-strings are, how they differ from f-strings, how to write a template processor, and practical use cases for HTML escaping, SQL parameterization, and shell safety. By the end you’ll understand when to reach for a T-string instead of an f-string, and how to build processors that make them genuinely useful.
T-Strings: Quick Example
Before diving into theory, here’s the simplest possible T-string to show you the key difference from an f-string:
# t_string_intro.py
from string.templatelib import Template, Interpolation
name = "Alice"
# An f-string produces a plain str immediately
f_result = f"Hello, {name}!"
print(type(f_result), repr(f_result))
# A T-string produces a Template object — NOT a string yet
t_result = t"Hello, {name}!"
print(type(t_result))
# The Template holds the parts separately
for part in t_result:
print(repr(part))
Output:
# quick_example.py
<class 'str'> 'Hello, Alice!'
<class 'string.templatelib.Template'>
'Hello, '
Interpolation(value='Alice', expression='name', conversion=None, format_spec='')
'!'
Notice that the T-string didn’t produce "Hello, Alice!" — it produced a Template object. The static parts ("Hello, " and "!") are strings, and the interpolated value (name) is wrapped in an Interpolation object that carries the runtime value, the original expression text, and any format spec or conversion. Nothing has been combined yet. That’s the power: you decide how they’re combined, and you can transform any interpolated value in the process.
Keep reading to understand the full structure and how to build processors that use it.
What Are T-Strings and Why Do They Exist?
T-strings (template strings, PEP 750) are a new string prefix introduced in Python 3.14. The syntax is t"..." — just like an f-string but with t instead of f. You can use the same expression syntax inside the curly braces: variables, attribute access, method calls, format specs (:.2f), and conversion flags (!r, !s, !a).
The crucial difference is in what you get back. An f-string calls str() on every interpolated value and concatenates everything into a plain string before returning. A T-string returns a Template object that holds the parts unevaluated (well, the expressions are evaluated, but they’re not yet combined with the static parts). This lets code downstream decide how to combine them.
| Feature | F-string (f"...") | T-string (t"...") |
|---|---|---|
| Return type | str | Template |
| Values combined | Immediately, eagerly | Not until a processor runs |
| Can inspect interpolated values | No (already stringified) | Yes — each is an Interpolation |
| Can sanitize values | Only manually before interpolation | Yes — in the processor |
| Knows original expression text | No | Yes — interpolation.expression |
| Suitable for HTML/SQL/shell | Risky without manual escaping | Safe with an appropriate processor |
The Template object is iterable: iterating over it yields a sequence of str and Interpolation objects, alternating between static text and interpolated values. You can also access .strings (a tuple of the static string parts) and .interpolations (a tuple of Interpolation objects). A processor is just a function that accepts a Template and returns whatever it wants — a string, an HTML node, a parameterized query object, anything.

The Template Object Structure
To write a useful processor, you need to understand exactly what a Template contains. Let’s explore the full structure with a more complex example:
# template_structure.py
from string.templatelib import Template, Interpolation
price = 9.99
item = "coffee"
qty = 3
tmpl = t"You ordered {qty} x {item!r} at ${price:.2f} each."
print("strings:", tmpl.strings)
print("interpolations:")
for interp in tmpl.interpolations:
print(f" value={interp.value!r}, expr={interp.expression!r}, "
f"conversion={interp.conversion!r}, format_spec={interp.format_spec!r}")
print("\nIterating the template:")
for part in tmpl:
if isinstance(part, str):
print(f" str: {part!r}")
else:
print(f" Interpolation: value={part.value!r}")
Output:
strings: ('You ordered ', ' x ', ' at $', ' each.')
interpolations:
value=3, expr='qty', conversion=None, format_spec=''
value='coffee', expr='item', conversion='r', format_spec=''
value=9.99, expr='price', conversion=None, format_spec='.2f'
Iterating the template:
str: 'You ordered '
Interpolation: value=3
str: ' x '
Interpolation: value='coffee'
str: ' at $'
Interpolation: value=9.99
str: ' each.'
The .strings tuple always has exactly one more element than .interpolations — there’s always a static string between (and around) every interpolation, even if it’s an empty string. The conversion field stores the conversion flag ('r', 's', 'a', or None), and format_spec stores the format specification string. When you write a processor, you’re responsible for applying these — or choosing to ignore them for your own sanitization purposes.
Writing Your First Template Processor
A processor is just a callable that takes a Template and returns something. The simplest processor reconstructs a plain string exactly like an f-string would — useful as a starting point for understanding the pattern:
# simple_processor.py
from string.templatelib import Template, Interpolation
def to_str(template: Template) -> str:
"""Reconstruct a plain string from a Template — same as f-string behavior."""
parts = []
for part in template:
if isinstance(part, str):
parts.append(part)
else:
# Apply conversion if present
value = part.value
if part.conversion == 'r':
value = repr(value)
elif part.conversion == 's':
value = str(value)
elif part.conversion == 'a':
value = ascii(value)
else:
value = str(value)
# Apply format spec if present
if part.format_spec:
value = format(part.value, part.format_spec)
parts.append(value)
return ''.join(parts)
name = "World"
result = to_str(t"Hello, {name}!")
print(result)
Output:
Hello, World!
This processor is not interesting on its own — it’s identical to an f-string. The magic happens when the processor transforms interpolated values before combining them. Let’s build something genuinely useful.

HTML-Safe Processor: Preventing XSS
The most immediately practical use of T-strings is building an HTML processor that automatically escapes interpolated values unless they’re explicitly marked as safe. This is exactly what template engines like Jinja2 do — now you can do it natively in Python with no dependencies:
# html_processor.py
import html
from string.templatelib import Template, Interpolation
class SafeHTML:
"""Wraps a string that should NOT be escaped — it's already safe HTML."""
def __init__(self, value: str):
self.value = value
def __str__(self):
return self.value
def safe(value: str) -> SafeHTML:
"""Mark a string as trusted HTML — it won't be escaped."""
return SafeHTML(value)
def HTML(template: Template) -> str:
"""
Template processor that HTML-escapes all interpolated values,
unless they're wrapped in SafeHTML (via the safe() helper).
"""
parts = []
for part in template:
if isinstance(part, str):
parts.append(part)
elif isinstance(part.value, SafeHTML):
parts.append(str(part.value))
else:
value = part.value
if part.conversion == 'r':
value = repr(value)
elif part.format_spec:
value = format(value, part.format_spec)
else:
value = str(value)
parts.append(html.escape(value))
return ''.join(parts)
# Safe usage — user input is automatically escaped
username = "<script>alert('xss')</script>"
score = 42.5
output = HTML(t"<p>Welcome, {username}! Your score: {score:.1f}</p>")
print(output)
# Trusted HTML can be passed through without double-escaping
trusted_link = safe('<a href="/profile">View Profile</a>')
output2 = HTML(t"<p>Welcome back! {trusted_link}</p>")
print(output2)
Output:
<p>Welcome, &lt;script&gt;alert('xss')&lt;/script&gt;! Your score: 42.5</p>
<p>Welcome back! <a href="/profile">View Profile</a></p>
The attack string has been safely escaped. The trusted link passes through untouched because it’s wrapped in SafeHTML. This pattern gives you XSS protection by default with an explicit opt-out for trusted content — the opposite of the “escape manually when you remember” approach that causes vulnerabilities.
SQL Parameterization Processor
The same principle applies to SQL. Instead of building query strings with f-strings (which invites SQL injection), you can use a T-string processor that extracts values into a parameters list and produces a parameterized query tuple:
# sql_processor.py
from string.templatelib import Template, Interpolation
def SQL(template: Template) -> tuple[str, list]:
"""
Template processor that produces a (query, params) tuple for safe DB execution.
Replaces interpolated values with ? placeholders and collects values separately.
"""
query_parts = []
params = []
for part in template:
if isinstance(part, str):
query_parts.append(part)
else:
query_parts.append('?')
params.append(part.value)
return ''.join(query_parts), params
# Build a safe parameterized query
user_id = 42
status = "active"
query, params = SQL(t"SELECT * FROM users WHERE id = {user_id} AND status = {status}")
print("Query:", query)
print("Params:", params)
# Demonstrate with sqlite3
import sqlite3
conn = sqlite3.connect(':memory:')
conn.execute("CREATE TABLE users (id INTEGER, name TEXT, status TEXT)")
conn.execute("INSERT INTO users VALUES (42, 'Alice', 'active')")
conn.execute("INSERT INTO users VALUES (99, 'Bob', 'inactive')")
conn.commit()
rows = conn.execute(query, params).fetchall()
print("Results:", rows)
conn.close()
Output:
Query: SELECT * FROM users WHERE id = ? AND status = ?
Params: [42, 'active']
Results: [(42, 'Alice', 'active')]
The values never touch the SQL string — they travel separately as parameters and are bound by the database driver. Even if user_id or status contained SQL injection payloads like '; DROP TABLE users; --, they’d be treated as data, not SQL syntax.
Composing and Nesting Templates
T-strings can be composed — you can include the result of one T-string inside another as an interpolated value, and a processor can handle nested templates recursively. This lets you build complex outputs from reusable template fragments without losing the safety properties:
# composing_templates.py
from string.templatelib import Template, Interpolation
import html
class SafeHTML:
def __init__(self, value: str):
self.value = value
def __str__(self):
return self.value
def HTML(template: Template) -> SafeHTML:
"""Returns SafeHTML so composed templates pass through unescaped."""
parts = []
for part in template:
if isinstance(part, str):
parts.append(part)
elif isinstance(part.value, SafeHTML):
parts.append(str(part.value))
elif isinstance(part.value, Template):
parts.append(str(HTML(part.value)))
else:
parts.append(html.escape(str(part.value)))
return SafeHTML(''.join(parts))
def render_item(name: str, price: float) -> SafeHTML:
return HTML(t"<li><strong>{name}</strong> — ${price:.2f}</li>")
items = [("Coffee", 3.50), ("Bagel", 2.25), ("OJ", 4.00)]
item_html = SafeHTML('\n'.join(str(render_item(n, p)) for n, p in items))
page = str(HTML(t"<ul>\n{item_html}\n</ul>"))
print(page)
Output:
<ul>
<li><strong>Coffee</strong> — $3.50</li>
<li><strong>Bagel</strong> — $2.25</li>
<li><strong>OJ</strong> — $4.00</li>
</ul>
Each render_item call produces a SafeHTML that the outer HTML() processor trusts and passes through without escaping. The item names and prices are still escaped individually inside their own template. You get composability without sacrificing safety.
Real-Life Example: A Minimal Email Template Engine
Let’s pull everything together and build a small email templating system that produces safe, properly formatted HTML emails. The processor handles HTML escaping and basic layout rendering:
# email_engine.py
import html
from string.templatelib import Template, Interpolation
from dataclasses import dataclass
class SafeHTML:
def __init__(self, value: str): self.value = value
def __str__(self): return self.value
def safe(value: str) -> SafeHTML:
return SafeHTML(value)
def HTML(template: Template) -> str:
"""HTML processor: escapes interpolations, passes through SafeHTML."""
parts = []
for part in template:
if isinstance(part, str):
parts.append(part)
elif isinstance(part.value, SafeHTML):
parts.append(str(part.value))
else:
value = part.value
if part.format_spec:
value = format(value, part.format_spec)
else:
value = str(value)
parts.append(html.escape(value))
return ''.join(parts)
@dataclass
class User:
name: str
email: str
plan: str
days_left: int
def render_welcome_email(user: User) -> str:
greeting_style = 'font-size:24px;color:#2d3748;margin-bottom:16px'
body_style = 'font-size:16px;color:#4a5568;line-height:1.6'
cta_style = ('display:inline-block;background:#4299e1;color:white;'
'padding:12px 24px;border-radius:6px;text-decoration:none')
return HTML(t"""
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:32px">
<h1 style="{safe(greeting_style)}">Welcome, {user.name}!</h1>
<p style="{safe(body_style)}">
Thanks for subscribing to the <strong>{user.plan}</strong> plan.
You have <strong>{user.days_left} days</strong> remaining in your trial.
</p>
<p style="{safe(body_style)}">
Your account is registered under: <code>{user.email}</code>
</p>
<a href="https://pythonhowtoprogram.com/dashboard" style="{safe(cta_style)}">
Go to Dashboard
</a>
</div>
""")
malicious_user = User(
name='<script>alert("hacked")</script>',
email='hacker@evil.com',
plan='Pro & "Elite"',
days_left=14
)
email_html = render_welcome_email(malicious_user)
print(email_html[:600])
Output (truncated):
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:32px">
<h1 style="font-size:24px;color:#2d3748;margin-bottom:16px">Welcome, <script>alert("hacked")</script>!</h1>
<p style="font-size:16px;color:#4a5568;line-height:1.6">
Thanks for subscribing to the <strong>Pro & "Elite"</strong> plan.
You have <strong>14 days</strong> remaining in your trial.
</p>
The script injection attempt is fully neutralized — the malicious name renders as escaped text. The inline styles pass through untouched because they’re wrapped in safe(). This email template engine is safe by construction, not by discipline.
Frequently Asked Questions
What Python version do I need for T-strings?
T-strings are introduced in Python 3.14, which was in alpha/beta as of early 2026. You’ll need Python 3.14 or later installed from python.org or via pyenv install 3.14-dev. T-strings are not available in 3.13 or earlier and there’s no backport since they require new syntax support in the parser.
Should I replace all my f-strings with T-strings?
No. F-strings are the right choice whenever you want a string and you’re not in a security-sensitive context. T-strings add overhead — creating a Template object and running a processor — and they require a processor before you can get a string out. Use T-strings when the separation between static text and interpolated values is meaningful: XSS prevention, SQL injection prevention, structured logging, or localization systems that need to inspect expressions.
Does the standard library include built-in processors?
Python 3.14 ships with string.templatelib, which provides the Template and Interpolation classes. As of the initial release, no built-in processors like HTML() or SQL() are included in the stdlib — you write your own or use third-party libraries. The ecosystem is still young, but expect libraries like Jinja2, Django, and SQLAlchemy to add T-string support once 3.14 stabilizes.
Are T-string expressions evaluated lazily?
No — expressions inside T-strings are evaluated eagerly, just like f-strings. When Python encounters t"Hello, {user.name}!", it evaluates user.name immediately and stores the resulting value inside the Interpolation object. What’s deferred is the combination of the static and dynamic parts into a final output. If you need truly lazy evaluation, you’d need a different approach.
Can I use T-strings with multiline strings and triple quotes?
Yes — T-strings support all the same syntactic forms as f-strings: t"...", t'...', t"""...""", t'''...''', and combinations with r for raw strings (rt"..."). The same rules apply inside the curly braces — you can use any Python expression, including function calls, conditional expressions, and nested f-strings.
Are T-strings slower than f-strings?
Yes, by design. Creating a Template object with Interpolation instances takes more work than simple string concatenation, and running a processor adds overhead. In tight loops generating millions of strings, this matters. For typical web request handling, email rendering, or database query building, the overhead is negligible compared to I/O. Profile your specific use case, but in most real-world code the safety benefits far outweigh the cost.
Conclusion
T-strings are Python 3.14’s answer to a problem that’s been solved ad hoc for decades: how do you build structured output from user-controlled data without putting the entire burden of safety on the programmer’s memory? By separating static text from interpolated values at the language level, T-strings give processors the information they need to enforce invariants — escape HTML, parameterize SQL, validate shell arguments — before a single dangerous byte reaches the output.
We covered the Template and Interpolation object structure, built a plain-string processor to understand the pattern, then progressed to a full HTML escaping processor, a SQL parameterization processor, and a composable email template engine. The real-life example showed that even with actively malicious input, T-string processors produce safe output by construction rather than by discipline.
T-strings are new territory — the ecosystem of processors and best practices is still forming. Explore the PEP 750 reference and the string module documentation as they evolve. The best way to learn is to build your own processor for a domain you care about.