TL;DR
Python 3.14 ships t-strings (PEP 750), a new string literal that looks like an f-string but returns a Template object instead of a finished str. You get the static parts and the interpolated values separately, so a library author can sanitize, escape, parameterize, or defer the rendering. I rewrote a small SQLite logger I keep on my laptop using t-strings and the diff was about ten lines, but the SQL injection class of bug is now structurally impossible. Library authors will get the most use out of them; application code will mostly read t-strings rather than write them.
Why f-strings stop being enough
I have been writing Python since 2.6 and f-strings, introduced in 3.6, were a clear win. They replaced % formatting and .format() for almost everything I do. The catch is that f-strings evaluate immediately: the moment you write f"... {x} ...", Python calls str.__format__ on each interpolated value and concatenates the result. There is no hook, no transform, no chance for a library to inspect what got plugged into the gaps.
That sounds academic until you watch a junior engineer write cursor.execute(f"SELECT * FROM users WHERE name = '{name}'") for the third time. The “use parameterized queries” lecture is technically correct and operationally ignored, because the f-string syntax is too inviting. The Python 3.14 release notes from the Python 3.14.4 page call this out indirectly: PEP 750 lists “domain-specific languages that need string-like syntax with safe interpolation” as the headline use case.
T-strings close that hole. Instead of producing a str, the literal t"..." produces a string.templatelib.Template instance. The library author decides what happens next.
Setup: getting Python 3.14 on your machine
You need Python 3.14 or newer. The current stable as of this post is 3.14.4 (April 7, 2026). On macOS I use uv because it manages interpreter installs without touching the system Python (I compared uv against pip and Poetry here if you want the long version):
$ uv python install 3.14
$ uv python pin 3.14
$ uv run python --version
Python 3.14.4
If you prefer pyenv or the official installer, both work. The point is that t-strings are syntax. There is no from __future__ import to backport them. A t"..." literal is a SyntaxError on 3.13 and earlier.
Already on Python 3.14? See my walkthrough of the free-threaded build for the GIL story that shipped alongside t-strings.
The shape of a Template object
Open a 3.14 REPL and try this:
>>> name = "Pythonista"
>>> site = "danilchenko.dev"
>>> template = t"Hello, {name}! Welcome to {site}!"
>>> type(template)
<class 'string.templatelib.Template'>
>>> template.strings
('Hello, ', '! Welcome to ', '!')
>>> template.interpolations
(Interpolation('Pythonista', 'name', None, ''),
Interpolation('danilchenko.dev', 'site', None, ''))
>>> template.values
('Pythonista', 'danilchenko.dev')
That output is the whole secret. Three observations:
stringsis a tuple of the literal fragments around your interpolations. There is always exactly one more string than there are interpolations (some may be empty).interpolationsis a tuple ofInterpolationobjects, each with four fields:value,expression,conversion, andformat_spec.- The order is implicit: the template alternates
strings[0], interpolations[0], strings[1], interpolations[1], .... To walk the alternation explicitly you iterate the template directly:for item in template: ....
The Interpolation class deserves a closer look because the expression field is what makes structured logging click:
>>> i = template.interpolations[0]
>>> i.value
'Pythonista'
>>> i.expression
'name'
>>> i.conversion # 'a', 'r', 's', or None
>>> i.format_spec # '' here, '04d' in t"{n:04d}", etc.
''
The library author can read i.expression to learn the source code of the placeholder, not just its evaluated value. That single attribute makes structured logs, SQL placeholder names, and i18n catalog keys trivial to build. None of that was reachable from f-strings.
A SQL helper that makes injection structurally impossible
Here is the shortest practical example I keep around. The function turns any t-string into a (query, params) pair compatible with sqlite3.execute():
# safe_sql.py
import sqlite3
from string.templatelib import Template
def parameterize(template: Template) -> tuple[str, tuple[object, ...]]:
if not isinstance(template, Template):
raise TypeError("safe_sql expected a t-string")
parts: list[str] = []
params: list[object] = []
for item in template:
if isinstance(item, str):
parts.append(item)
else:
parts.append("?")
params.append(item.value)
return "".join(parts), tuple(params)
def query(conn: sqlite3.Connection, template: Template):
sql, params = parameterize(template)
return conn.execute(sql, params)
Now use it:
>>> import sqlite3
>>> from safe_sql import parameterize, query
>>> conn = sqlite3.connect(":memory:")
>>> conn.execute("CREATE TABLE users (name TEXT, age INT)")
>>> conn.execute("INSERT INTO users VALUES (?, ?)", ("Anna", 33))
>>> evil = "'; DROP TABLE users;--"
>>> sql, params = parameterize(t"SELECT * FROM users WHERE name = {evil}")
>>> sql
'SELECT * FROM users WHERE name = ?'
>>> params
("'; DROP TABLE users;--",)
>>> list(query(conn, t"SELECT * FROM users WHERE name = {evil}"))
[]
>>> list(query(conn, t"SELECT * FROM users WHERE age > {30}"))
[('Anna', 33)]
The injected payload lands in the parameter tuple. SQLite escapes it correctly because the SQL itself never contains the value — it contains a ?. Compare against the f-string version that everyone has typed at 11 PM:
# Don't do this. Ever.
sql = f"SELECT * FROM users WHERE name = '{evil}'"
conn.execute(sql)
# sqlite3.OperationalError: near "DROP": syntax error
# (and on a different DB it would have happily dropped the table)
The structural win is that parameterize only accepts a Template. If a junior writes query(conn, f"..."), your type checker of choice catches it at the type boundary, and at runtime the isinstance check raises immediately. The unsafe path requires affirmative effort to reach.
I tried this on a small budget tracker that lives in ~/code/buckets. The before-state was a smattering of f"UPDATE accounts SET balance = {amount} WHERE id = '{acct}'" calls written for an audience of one (me) but written badly enough that I would not run it as a service. After porting to t-strings the diff was 8 lines of changed source plus a 14-line safe_sql.py helper. Every place that used to take a string now takes a Template. The class of bug went away because the wrong shape no longer typechecks.
HTML escaping with the same pattern
The exact same skeleton produces an HTML helper. The PEP 750 reference and Real Python’s t-strings tutorial both show this; here is my version with the imports tightened:
# safe_html.py
from html import escape
from string.templatelib import Template
def render(template: Template) -> str:
if not isinstance(template, Template):
raise TypeError("safe_html expected a t-string")
out: list[str] = []
for item in template:
if isinstance(item, str):
out.append(item)
else:
out.append(escape(str(item.value), quote=True))
return "".join(out)
In use:
>>> from safe_html import render
>>> bad = "<script>alert('xss')</script>"
>>> render(t"<p>Hello, {bad}!</p>")
"<p>Hello, <script>alert('xss')</script>!</p>"
The static <p>...</p> passes through untouched because it is part of template.strings. The interpolated bad lands in template.interpolations, gets escaped, and only then concatenated. A reader cannot accidentally introduce XSS by writing user input into the template — the escaper sees user input as user input, not as a string fragment.
A more capable HTML library could special-case attribute interpolation, dict-of-attrs syntax, and component-style nesting. The PEP itself gestures at this with the t"<img {attributes} />" example where attributes is a dict.
Logging without paying for the format string
Python’s logging module has a long-standing performance trick: pass a format string and the args separately, like log.info("user %s logged in", user_id), so that %-formatting only runs if the log line actually fires. F-strings break this — the format runs at the call site whether or not INFO is enabled.
T-strings give you the trick back, plus structured context:
# t_log.py
import json
import logging
from string.templatelib import Template
class LazyTemplate:
"""A logging-safe wrapper that defers rendering."""
def __init__(self, template: Template):
if not isinstance(template, Template):
raise TypeError("LazyTemplate expected a t-string")
self._template = template
def __str__(self) -> str:
parts: list[str] = []
for item in self._template:
if isinstance(item, str):
parts.append(item)
else:
value = format(item.value, item.format_spec)
parts.append(value)
msg = "".join(parts)
ctx = {
i.expression: i.value
for i in self._template.interpolations
}
return f"{msg} | {json.dumps(ctx, default=str)}"
def info(template: Template) -> None:
logging.info("%s", LazyTemplate(template))
Used like this:
>>> import logging, t_log
>>> logging.basicConfig(level=logging.INFO, format="%(message)s")
>>> user, latency = "anna", 42.7
>>> t_log.info(t"login complete for {user} in {latency:.1f}ms")
login complete for anna in 42.7ms | {"user": "anna", "latency": 42.7}
When the level is raised to WARNING, the __str__ call never runs and the JSON dict is never built. You get human-readable messages and machine-readable context from one literal, with no extra cost when the log line is suppressed.
f-strings vs t-strings — a side-by-side cheat sheet
| Aspect | f-string (f"...") | t-string (t"...") |
|---|---|---|
| Return type | str | string.templatelib.Template |
| Evaluated when? | Immediately at the literal | Whenever the consumer iterates it |
| Where to use | Application code, print, simple formatting | Library APIs that take user-controlled values |
| Can a library hook in? | No — already concatenated | Yes — via template.strings and template.interpolations |
| Knows the source expression? | No | Yes — interpolation.expression |
Can replace any str? | Yes | No — needs a renderer first |
| Backportable? | No (3.6+) | No (3.14+ syntax) |
| Raw variant? | rf"..." | rt"..." or tr"..." |
The “Can replace any str?” row is the source of every gotcha. Because a Template is a separate type, you cannot pass it to print and expect formatted output, you cannot send it to a function that calls len() on it, and t"hi" + " there" raises TypeError. The library author has to provide a renderer, which is by design and which surprises people on the first day.
Caveats and gotchas worth knowing
A few things tripped me up the first week, in order of how much time each one cost me.
You cannot mix f and t prefixes. ft"..." is a SyntaxError. If you need both behaviors in one file, write two literals. The accepted prefix combinations are t, T, rt, Rt, rT, RT, tr, tR, Tr, TR. No others.
Template does not implement __len__ or __contains__. This is deliberate — the value can change once you render it, and a library author may render to something other than a string. If you want length, render first.
isinstance(x, Template) is the right check, not isinstance(x, str). I wasted thirty minutes on a function that did if not x: on a template, which calls __bool__, which is always truthy for templates, so type-check explicitly.
Empty static segments are still in template.strings. A literal t"{a}{b}" produces strings = ("", "", ""). Direct iteration over the template silently drops the empties, so for item in template: already does the right thing for renderers; the empties only show up if you read template.strings directly.
The expression field is the source text, not a variable lookup. t"{a + b}" gives an Interpolation whose expression is "a + b" and whose value is the evaluated result. Useful for debug logs; do not try to round-trip the expression back through eval.
There is no f-string to t-string converter. A linter could rewrite trivial cases, but in general the migration is a behavior change and has to be reviewed by hand. I ported the SQL spots first because the security argument made the priority obvious; the rest can wait until the helpers exist for them.
Subprocess support is still a draft. PEP 787 proposes letting subprocess.run(t"...", shell=True) shell-quote interpolated values automatically. As of 3.14.4 it is deferred — the authors plan to revise after experimental implementations land in the 3.14 beta cycle, with a target of 3.15. For now, write your own shlex.quote renderer if you need one.
When not to use t-strings
I keep seeing developers reach for t-strings everywhere because the security framing is compelling. Most code does not need them.
Application code that builds a one-shot human-readable message (a print statement, an exception text, a debug log) should keep using f-strings. The reason f-strings are so popular is that they are the right tool for the boring 90% of string formatting. T-strings only pay for themselves when there is a consumer of the literal that needs to inspect it. If the consumer is print, an f-string is shorter, faster, and easier to read.
The rule of thumb I am using: t-string the API, f-string the body. Library boundaries take templates; everything inside the function uses regular strings.
FAQ
What are t-strings in Python?
T-strings are a new string literal in Python 3.14, introduced by PEP 750. The syntax mirrors f-strings — t"hello {name}" — but the literal evaluates to a string.templatelib.Template instance instead of a str. The Template exposes the static fragments and interpolated values separately, so library code can intercept and transform them before final rendering.
How are t-strings different from f-strings?
F-strings produce a str immediately. T-strings produce a Template object. F-strings are convenient for application code; t-strings are designed for library APIs that need to sanitize, escape, parameterize, or defer the interpolation. You can iterate a Template to walk the alternation of static strings and Interpolation objects; you cannot do that with an f-string because the f-string is already collapsed into a flat string.
How do t-strings prevent SQL injection?
They do not prevent it on their own — they make a safe API expressible. Because the library function only ever sees the user input as interpolation.value, never as part of the SQL fragment, you can replace each interpolation with a ? placeholder and pass the values through the database driver’s parameter binding. The driver does the actual escaping. The structural change is that the unsafe path (raw f-string concatenation) is no longer the path of least resistance.
What Python version supports t-strings?
Python 3.14, released October 7, 2025, with the latest patch being 3.14.4 on April 7, 2026. T-strings are a syntactic feature, so there is no backport. A t"..." literal will raise SyntaxError on 3.13 and earlier.
Can you pass a t-string anywhere a string is expected?
No. Template is not a subclass of str. Passing one to print() will print the repr of the Template, not the rendered text. Concatenation with + raises TypeError. The library that consumes the t-string has to provide a renderer. This is by design. Silently coercing to str would defeat the security guarantees t-strings are built for.
Will t-strings replace f-strings?
No. F-strings remain the right tool for application-level string formatting. T-strings target library and DSL authors. Most Python users will write t-strings only when calling SQL, HTML, logging, i18n, or shell helpers, and will consume them rarely.
Sources
- PEP 750 — Template Strings — the accepted proposal that introduced t-strings, with the full motivation, rationale, and reference implementation.
- string.templatelib — Python 3.14.4 documentation — official module reference for
TemplateandInterpolation. - Python 3.14.4 release notes — the patch release used for examples in this post.
- What’s new in Python 3.14 — full changelog including t-strings, free-threading, and the experimental JIT.
- Real Python — Python 3.14: Template Strings — secondary tutorial with additional examples used to cross-check the SQL and HTML helpers.
- PEP 787 — Safer subprocess usage using t-strings — deferred proposal for
subprocessandshlexintegration.
Bottom line
T-strings are a small syntax change with most of the impact concentrated in library APIs. Your daily print(f"hello {name}") keeps working as before. But over the next few years, expect sqlite3, psycopg, httpx, subprocess, and the structured logging libraries to grow t-string-aware constructors. The code samples in this tutorial are short on purpose: once you understand template.strings and template.interpolations, every other helper is a variation on the same loop. Try it on the next SQL or HTML hot spot in your codebase. The diff is small, and the class of bug it removes is large.