Python Just Got a New Kind of String. It Could End a Whole Class of Security Bugs.
t-strings (PEP 750) are the biggest change to Python string formatting since f-strings, and they exist for one reason: f-strings cannot be made safe. Here is what they are and why they matter. Every SQL injection, every
t-strings (PEP 750) are the biggest change to Python string formatting since f-strings, and they exist for one reason: f-strings cannot be made safe. Here is what they are and why they matter.
Every SQL injection, every cross-site scripting hole, every shell-injection bug has the same shape at its heart. Somebody took a piece of untrusted input and glued it directly into a string that was then treated as code, as a query, as markup. The input became structure instead of data.
query = f"SELECT * FROM users WHERE name = '{name}'"
If name is O'Brien, the apostrophe closes the quoted value early and the query breaks. If name is crafted by an attacker, the same mechanism lets them add their own commands to your query.
The f-string did exactly what it was designed to do: take the value and put it in the string. Immediately. Finished. And that immediacy is precisely the problem. By the time you have the string, the dangerous value is already welded into it, indistinguishable from the safe parts. There is no seam left to escape.
Python 3.14 introduced a new kind of string literal, standardized in PEP 750, built specifically to fix this. They are called t-strings (template strings), and as the Python 3.15 world settles in late 2026, they are the most consequential change to string handling since f-strings arrived.
Here is what they are, how they differ from f-strings in one crucial way, and why that difference can close an entire category of vulnerabilities.
The One Difference That Changes Everything
A t-string looks almost exactly like an f-string. Same braces, same interpolation syntax. The only visible change is the prefix: t instead of f.
name = "Pythonista"
greeting = t"Hello, {name}!"
But the result is completely different in the way that matters. An f-string evaluates to a finished str. A t-string evaluates to a Template object, from the new string.templatelib module. Not a string. A structured object.
type(t"Hello, {name}!")
# <class 'string.templatelib.Template'>
And here is the heart of it.
-
An f-string does the interpolation eagerly. It grabs the value of
name, drops it into the text, and hands you a done string with no memory of which parts were literal and which came from a variable. - A t-string does not interpolate into a final string at all. Instead, it keeps the pieces separate: the static text parts on one side, the interpolated values on the other, handed to you as a structured object you can inspect and process before anything becomes a finished string.
The f-string gives you the result. The t-string gives you the recipe, with the dangerous ingredients still in their own labeled containers, so a library can treat them differently from the trusted parts before combining them.
What a Template Actually Holds
Because a Template keeps the pieces apart, you can look at them. It exposes its structure directly:
name = "Pythonista"
site = "example.dev"
template = t"Hello, {name}! Welcome to {site}!"
template.strings
# ('Hello, ', '! Welcome to ', '!') <- the static text parts
template.values
# ('Pythonista', 'example.dev') <- the interpolated values
template.interpolations
# (Interpolation('Pythonista', 'name', ...), Interpolation('example.dev', 'site', ...))
Three things to notice:
-
.stringsholds the literal text fragments you typed, the parts that are always safe because you wrote them. There is always exactly one more static string than there are interpolations (the text before, between, and after the holes). -
.interpolationsholds the dynamic parts, each as anInterpolationobject. EachInterpolationcarries not just the.value(the actual runtime value,'Pythonista') but also the.expression(the source text you wrote in the braces,'name'), plus the conversion and format spec. - The value and the literal text are kept in completely separate buckets.
You can also just iterate the template, and it alternates between static strings and Interpolation objects in order:
from string.templatelib import Interpolation
for part in template:
if isinstance(part, Interpolation):
... # a dynamic value, treat it as untrusted
else:
... # a static string, trusted, you wrote it
That loop is the key. It lets code walk through the template and do one thing to the parts you wrote and a different thing to the parts that came from outside. That is exactly the distinction every injection bug fails to make.
The Safety Payoff, Concretely
Here is why this matters, shown with HTML and the classic cross-site scripting problem. To keep the example simple, imagine a user submits markup where plain text was expected.
With an f-string, untrusted input goes straight into the page, raw:
user_input = "<b>injected markup</b>"
f"<p>{user_input}</p>"
# "<p><b>injected markup</b></p>"
The f-string is already a finished string, and the user's tags are now live markup, exactly as if you had written them yourself. It is too late to escape; you cannot tell anymore which angle brackets you wrote and which came from the user. In a real attack, those tags would carry a script instead of bold text.
With a t-string, you hand the Template to a function that walks it and escapes only the interpolated values, leaving your static markup untouched:
from html import escape
from string.templatelib import Template, Interpolation
def to_safe_html(tpl: Template) -> str:
out = []
for part in tpl:
if isinstance(part, Interpolation):
out.append(escape(str(part.value), quote=True)) # untrusted: escape it
else:
out.append(part) # trusted: your own text
return "".join(out)
bad = "<script>alert('x')</script>"
to_safe_html(t"<p>{bad}</p>")
# '<p><script>alert('x')</script></p>' <- neutralized
The interpolated value is treated as untrusted and escaped, while the static text is your own and passes through unchanged. The user's tags are now harmless visible text, while your <p> tags pass through intact.
One writer put the principle perfectly: the escaper "sees user input as user input, not as a string fragment."
Now generalize. The same pattern applies everywhere injection lives:
- A SQL library can take a t-string and turn every interpolation into a bound parameter instead of concatenated text, which removes the main route for injection.
- A shell library can quote every interpolated argument.
- A logging library can redact.
In each case the library gets to decide how to handle the dynamic parts because they arrived separately, which is the one thing f-strings never allow.
The Deliberate Inconvenience: A Template Is Not a String
There is a design choice in t-strings that looks like a limitation and is actually the point. A Template does not render itself into a string by default. You cannot just use it where a string is expected.
- Printing one shows its
repr, not interpolated text. - You cannot add a plain string to a
Templatewith+(that raisesTypeError; onlyTemplate + Templateis allowed). - It has no
len().
It genuinely is not a string, and it refuses to pretend to be one.
print(t"Hello, {name}!")
# Template(strings=('Hello, ', '!'), interpolations=(Interpolation('Pythonista', 'name', None, ''),))
That is the structured object, not the text "Hello, Pythonista!".
This feels annoying until you see why. If a Template quietly turned itself into a string the moment you used it carelessly, it would reintroduce the exact danger it exists to prevent: an un-escaped interpolation sliding into output because someone forgot to process it.
By requiring that every template be run through a processing function before it becomes text, the design makes "forgot to escape" much harder. You cannot accidentally use the raw version, because the raw version is not usable as a string.
The inconvenience is a guardrail.
So t-strings do not escape anything on their own. They are the mechanism that lets a library escape safely. The t-string carries the pieces, apart and inspectable; the library does the right thing with them. What Python standardized is the shape, so every library can speak it.
The Rule I Take Away
Compressed to one line:
An f-string interpolates eagerly into a finished
str, fusing trusted text and untrusted values together with no seam left to escape, which is why it is unsafe for SQL, HTML, and shell. A t-string produces aTemplatethat keeps the static parts (.strings) and the interpolated values (.interpolations, each with.value) separate, so a library can escape or parameterize the untrusted parts before building the final string.
The deeper lesson, the one that outlasts this specific feature, is that safety against injection comes from preserving the boundary between structure and data for as long as possible.
Every injection vulnerability is a place where that boundary was erased too early, where a value became part of the sentence before anyone could treat it as a value. F-strings erase the boundary instantly, by design, because they were built for convenience, not safety. T-strings keep the boundary intact and hand it to you, which is what finally lets Python libraries offer "safe by construction" interpolation the way other ecosystems have with tagged templates.
And the practical reality as of today: t-strings are new (3.14, carried into the 3.15 world), and the libraries that consume them are still growing. You will not rewrite everything overnight. But the direction is set, and when a SQL, HTML, or shell library offers a t-string API, reaching for it instead of an f-string is one of the cheapest security upgrades you can make.
What to Actually Do With This
Concrete guidance for where t-strings are right now:
| Situation | Use | Why |
|---|---|---|
| Logging a number, debug messages, formatting output you control | f-strings | Perfect there and not going anywhere. The problem is only ever untrusted input crossing into code, query, or markup. |
| SQL queries, HTML output, shell commands with values you didn't write | t-strings + t-string-aware libraries | That is the boundary t-strings were built to protect. |
And two standing rules:
- Never build SQL, HTML, or shell commands with f-strings and raw interpolation. This was always true; t-strings now give you a first-class, standard alternative instead of only "remember to use parameterized queries / escape manually."
- Watch for t-string support in your libraries. As database drivers, template engines, and shell helpers add t-string APIs, prefer them. The whole value of PEP 750 is a single standard shape that every library can accept, so the ecosystem can make the safe path the easy path.
Python did not just add a nicer way to format strings. It added a way to hand libraries the raw ingredients of a string with the dangerous ones still labeled, so they can be made safe before serving. That is a small syntax change pointed at one of the oldest and most damaging categories of bug in software.
The 30-Second Takeaway
-
t-strings (PEP 750, Python 3.14+) look like f-strings but use a
tprefix and evaluate to aTemplateobject (string.templatelib), not astr. - The key difference: an f-string interpolates eagerly into a finished string, fusing your text and the values together with no way left to escape them. A t-string keeps the pieces separate for you to process first.
-
A
Templateexposes its structure:.strings(the static text you wrote, always one more than the values),.interpolations(each anInterpolationwith.valuethe runtime value and.expressionthe source text), and.values. Iterating it alternates static strings and interpolations. - The security payoff: a library can walk the template and escape or parameterize only the interpolated (untrusted) values, leaving your static markup or query intact, before building the string. This removes the main route for SQL injection, XSS, and shell injection.
-
A
Templateis deliberately not a string: no default render, adding a plain string with+raisesTypeError, nolen(). This forces you to process it, so you cannot accidentally emit un-escaped output. The inconvenience is a guardrail. - t-strings do not escape by themselves; they are the standard mechanism that lets libraries escape safely. Use f-strings for safe internal formatting; use t-strings (and t-string-aware libraries) wherever untrusted input meets SQL, HTML, or shell.
Your Turn
Find one place in your code where you build SQL, HTML, or a shell command with an f-string and an interpolated value you did not write. That is an injection risk today. As your libraries add t-string support, that exact spot is where the t prefix becomes the cheapest security fix you will ever ship.
Have you tried t-strings yet? What's the first place you'd use them? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.