TL;DR
Python t-strings (PEP 750) look like f-strings, but t"..." builds a string.templatelib.Template object where f"..." builds a finished str. The static text and the interpolated values stay separate, so a library decides what happens to each value. This tutorial builds three small helpers on top of that: SQL with ? placeholders instead of pasted values, HTML that escapes every interpolation, and logging that skips the formatting work when the level is off. It also covers psycopg 3.3’s built-in t-string support, an f-strings vs t-strings cheat sheet, and eight gotchas. You need Python 3.14 (latest patch: 3.14.8, September 30, 2026). Library authors get the most out of t-strings; application code will mostly pass them along to those libraries.
Why f-strings stop being enough
F-strings, introduced in 3.6, were a clear win. They replaced % formatting and .format() for almost everything. The catch is that f-strings evaluate immediately: the moment you write f"... {x} ...", Python calls format() on each interpolated value and concatenates the result. A library never gets 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 correct and mostly ignored, because the f-string syntax is too inviting. PEP 750 names the gap in its motivation section: “f-strings provide no way to intercept and transform interpolated values before they are combined into a final string.”
T-strings close that hole by handing the library a string.templatelib.Template instance in place of a finished str.
Setup: getting Python 3.14 on your machine
You need Python 3.14 or newer. The latest 3.14 patch as of this update is 3.14.8, released September 30, 2026. On macOS, uv is the easy route 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 # should report a 3.14.x patch release
If you prefer pyenv or the official installer, both work. T-strings are syntax, so there’s no from __future__ import to backport them, and a t"..." literal is a SyntaxError on 3.13 and earlier.
Python 3.15.0 lands in October 2026: PEP 790 sets the final release for October 9, after a third release candidate on October 2. It changes very little for t-strings: the only t-string entry in the 3.15 What’s New is t-string support in pprint. The headline 3.15 features are elsewhere, mainly lazy imports and frozendict, which the Python 3.15 overview walks through.
For the GIL side of 3.14, see the free-threaded build walkthrough; it 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')
(The t-string outputs in this post follow the string.templatelib docs and PEP 750 rather than a live 3.14 session. The October 2026 update was checked on Python 3.12, so the only outputs re-executed were the ones that need no t-strings: the f-string SQLite failure and the html.escape result.)
Three things to notice in that output:
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 parts in order, iterate the template directly (for item in template: ...), which skips empty strings.
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 get the source code of the placeholder as well as its value. That one attribute is enough to build structured-log keys, named SQL placeholders, or i18n catalog keys, none of which an f-string exposes.
A SQL helper that only accepts parameterized input
The function below turns any t-string into a (query, params) pair that sqlite3.Connection.execute() accepts:
# 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 treats it as data, because the SQL text only has a ? where the value goes.
What the f-string version actually does
Compare that with the f-string version. This part needs no t-strings, so I re-ran it for the October 2026 update on Python 3.12.3 with SQLite 3.45.1:
# fstring_injection.py: the unsafe baseline (no t-strings; error text as of Python 3.11+)
import sqlite3
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 = f"SELECT * FROM users WHERE name = '{evil}'" # don't do this
print(repr(sql))
try:
conn.execute(sql)
except sqlite3.Error as exc:
print(f"{type(exc).__name__}: {exc}")
conn.executescript(sql) # the method meant for running .sql files
print(conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall())
Running it prints:
"SELECT * FROM users WHERE name = ''; DROP TABLE users;--'"
ProgrammingError: You can only execute one statement at a time.
[]
The plain execute() call survives only because Python’s sqlite3 module refuses to run two statements in one call. On Python 3.6 to 3.10 that refusal is a sqlite3.Warning, which except sqlite3.Error doesn’t catch, so the script stops there. Route the same string through executescript() and the injected DROP TABLE runs: the last query finds zero tables. Drivers differ in whether one call may carry several statements, so an exception in your test run proves nothing about the next database you deploy against. Parameter binding stays safe whatever a driver’s multi-statement rules are.
Because parameterize only accepts a Template, a teammate who writes query(conn, f"...") gets caught twice: your type checker of choice flags it at the type boundary, and at runtime the isinstance check raises immediately.
Picture a small budget-tracker script full of f"UPDATE accounts SET balance = {amount} WHERE id = '{acct}'" calls. That’s fine for one user and risky behind a web endpoint. Porting it to t-strings takes the safe_sql.py helper plus a handful of changed call sites, and every place that used to take a string now takes a Template. The injection risk goes away because the wrong shape no longer typechecks.
Postgres: psycopg 3.3 already takes t-strings
For Postgres you don’t need a homemade helper. Psycopg 3.3, released December 1, 2025, accepts a t-string directly in execute() when it runs on Python 3.14. Interpolated values become bound parameters (or are quoted correctly when client-side binding is in use), and the format spec after the colon tells psycopg how to treat each slot. The template string docs list these specifiers:
| Specifier | What psycopg does with the value |
|---|---|
none or :s | Parameter, format picked automatically |
:b / :t | Parameter sent in binary / text format |
:i | Identifier, such as a table or column name, quoted |
:l | Literal merged into the query on the client side |
:q | SQL snippet included verbatim (sql.SQL or sql.Composed) |
# needs Python 3.14+ and psycopg >= 3.3
import psycopg
def add_person(conn: psycopg.Connection, table: str, first: str, last: str) -> None:
conn.execute(
t"INSERT INTO {table:i} (first_name, last_name) VALUES ({first}, {last})"
)
:i handles a case placeholders can’t. Table names can’t be bound parameters in Postgres, which is why dynamic identifiers used to need the psycopg.sql composition API. With a t-string, the identifier and the values sit in one readable literal, and each still goes through the right escaping path. (The snippet needs a live Postgres server and follows psycopg’s documented example; it wasn’t run for this post.)
HTML escaping with the same pattern
The exact same skeleton produces an HTML helper. PEP 750’s motivation section sketches an html() function built on this idea, and Real Python’s t-strings tutorial covers XSS as well; here is a 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’s part of template.strings. The interpolated bad lands in template.interpolations, gets escaped, and only then concatenated; the escaped fragment is exactly what html.escape(bad, quote=True) returns on Python 3.12.3. A developer who drops user input into the template body gets it escaped automatically, because the renderer only ever sees it as an interpolated value.
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, because 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. One literal gives you a readable message plus structured context, and you pay no formatting cost when the line is suppressed (the interpolated values themselves are still computed at the call site).
f-strings vs t-strings: side-by-side cheat sheet
| Aspect | f-string (f"...") | t-string (t"...") |
|---|---|---|
| Return type | str | string.templatelib.Template |
| Values evaluated | Immediately, at the literal | Immediately, at the literal |
| Turned into text | Immediately, by Python | Whenever a consumer renders the Template |
| Where to use | Application code, print, simple formatting | Library APIs that take user-controlled values |
| Can a library hook in? | No, the text is already joined | Yes, via template.strings and template.interpolations |
| Knows the source expression? | No | Yes, interpolation.expression |
Can replace any str? | Yes | No, it needs a renderer first |
| Backportable? | No (3.6+) | No (3.14+ syntax) |
| Raw variant? | rf"..." | rt"..." or tr"..." |
Two rows explain most of the gotchas below. Values are computed when the literal runs, exactly as in an f-string; only the final joining into text waits for a renderer. And because a Template is a separate type, print shows its repr, len() raises, and t"hi" + " there" raises TypeError. The library author has to provide a renderer. That’s deliberate, and it surprises people at first.
Eight t-string gotchas
You can’t 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, and TR.
len() fails and in misleads. Template defines no __len__, so len(template) raises TypeError; until something renders it, there’s no single length to report. The in operator does work, because Python falls back to __iter__, but it checks the non-empty static parts and the Interpolation objects. With the earlier template, "Pythonista" in template is False, even though the rendered text contains it. Render first, then measure or search.
Templates compare by identity. The PEP is explicit that Template and Interpolation instances compare with is, so t"a" == t"a" is False. Don’t use templates as dict keys or memoization keys and expect two equal-looking literals to match. Compare template.strings and template.values if you need value equality.
Check with isinstance(x, Template). A Template isn’t a str, and with no __bool__ or __len__ it’s always truthy, so if not x: never catches an empty one (t"" included).
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.
expression holds the source text. t"{a + b}" gives an Interpolation whose expression is "a + b" and whose value is the evaluated result. That’s handy for debug logs, but don’t try to round-trip the expression back through eval.
There is no f-string to t-string converter. The migration changes behavior, so it has to be reviewed by hand. Port the SQL spots first, since the security argument makes the priority obvious. A linter can at least build the worklist: Ruff’s S608 rule, ported from flake8-bandit, flags SQL-looking strings built by interpolation. Its implementation matches f-strings that contain a placeholder and currently skips t-strings (the source marks that choice with a TODO), so ruff check --select S608 shrinks as you port call sites. If you haven’t moved to Ruff yet, the Ruff vs Flake8 vs Black comparison covers the switch.
Subprocess support is still deferred. PEP 787 proposes letting subprocess.run(t"...", shell=True) shell-quote interpolated values automatically. As of October 2026 its status is still Deferred: the authors planned to build an experimental implementation during the 3.14 beta period and resubmit for 3.15, and the 3.15 What’s New contains no subprocess or shlex t-string support. For now, write your own shlex.quote renderer if you need one.
When not to use t-strings
It’s tempting to reach for t-strings everywhere because the security framing is compelling, but most code doesn’t 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, which handle the bulk of everyday formatting perfectly well. 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 and easier to read.
A good rule of thumb: 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?
Both evaluate their {...} expressions immediately. An f-string then joins everything into a str, while a t-string keeps the pieces in a Template object, which is why t-strings suit library APIs that need to sanitize, escape, parameterize, or render the values later. You can iterate a Template to walk its static strings and Interpolation objects in order. An f-string offers nothing to iterate except characters, since it’s already collapsed into a flat string.
How do t-strings prevent SQL injection?
Only through a library that uses them. The library function receives user input as interpolation.value, kept apart from the SQL text, so it can put a ? placeholder in its place and hand the value to the database driver’s parameter binding. The driver keeps the value out of the SQL text. With such a helper in place, the safe call is also the shortest one to type. On Postgres, psycopg 3.3 and later does this for you: conn.execute(t"... WHERE name = {name}") binds name as a parameter.
What Python version supports t-strings?
Python 3.14, released October 7, 2025. Every later release supports them too, including Python 3.15 (October 2026), which leaves the syntax unchanged. T-strings are a syntactic feature, so there is no backport: a t"..." literal raises SyntaxError on Python 3.13 and earlier.
Can you pass a t-string anywhere a string is expected?
No. Template isn’t a str subclass: print() shows its repr, and + with a str raises TypeError. The consuming library has to supply a renderer. That’s deliberate, since silently coercing to str would throw away the separation that makes t-strings safe.
Will t-strings replace f-strings?
No. F-strings stay the default for application-level string formatting, and 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.
For other recent Python changes, lazy imports target startup time, and the Python tooling guide covers the 2026 toolchain.
Sources
- PEP 750 — Template Strings: the accepted proposal that introduced t-strings, with the full motivation, rationale, and reference implementation.
- string.templatelib documentation: official module reference for
TemplateandInterpolation, including the iteration and concatenation rules. - Python 3.14.8 release page: the current 3.14 patch release (September 30, 2026).
- What’s new in Python 3.14: full changelog including t-strings, free-threading, and the experimental JIT.
- PEP 790 — Python 3.15 release schedule: release candidate dates and the planned October 9, 2026 final release.
- What’s new in Python 3.15: the 3.15 changelog, whose only t-string entry is
pprintsupport. - sqlite3 documentation:
execute()runs a single statement, whileexecutescript()runs several. - Psycopg 3.3 release announcement and template string query docs: t-string support in
execute()and the format specifiers. - Ruff rule S608 (hardcoded-sql-expression): the lint rule for SQL built by string interpolation.
- Real Python: Python 3.14 Preview: Template Strings (T-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. Adoption has started where the safety payoff is biggest: psycopg 3.3 takes t-strings in execute() today. I expect sqlite3, subprocess (once PEP 787 comes back), HTTP clients, and the structured logging libraries to follow with t-string-aware APIs over the next few releases. 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. Start with whatever ruff check --select S608 flags in your codebase; with a helper like parameterize in place, each fix is a one-line change at the call site.