Python has had five different ways to format strings over three decades. Percent formatting in 1991. str.format() in 2008. string.Template in 2004. F-strings in 2016. And now, in Python 3.14 — released October 7, 2025 — we have t-strings, and they're not just another syntax for putting variables into text.
T-strings are the first string literal in Python that doesn't return a string. They return a Template object. And that distinction changes everything about how you handle user input, build SQL queries, generate HTML, and compose shell commands in Python.
Here's the problem. F-strings are beautiful. They're readable. They're fast. And they're dangerous.
# This is an f-string. It's also a SQL injection vulnerability.
query = f"SELECT * FROM users WHERE name = '{user_input}'"
The moment user_input contains '; DROP TABLE users; --, your database is gone. F-strings evaluate to a plain str immediately — there's no opportunity to inspect, escape, or sanitize the interpolated values before they're baked into the final string. The string you get back has no memory of which parts came from your code and which parts came from a user.
Every Python developer knows you shouldn't do this. We all know to use parameterized queries. But f-strings make the wrong thing so easy and the right thing so verbose that people keep doing it anyway. The same pattern shows up with HTML (XSS attacks), shell commands (command injection), and regex (ReDoS).
PEP 750 — authored by Jim Baker, Guido van Rossum, Paul Everitt, Koudai Aono, Lysandros Nikolaou, and Dave Peck — was accepted by the Python Steering Council on April 10, 2025. It introduces template strings: a mechanism that gives you f-string syntax with structural awareness.
How T-Strings Actually Work
The syntax is identical to f-strings except you use a t prefix:
name = "Bobby Tables"
greeting = t"Hello, {name}!"
But here's the difference. greeting is not a string. It's a Template object:
>>> name = "Bobby Tables"
>>> template = t"Hello, {name}!"
>>> type(template)
<class 'string.templatelib.Template'>
>>> list(template)
['Hello, ', Interpolation(value='Bobby Tables', expression='name', conversion=None, format_spec=''), '!']
The Template object preserves the structure. It knows that "Hello, " and "!" are static strings that came from your source code. It knows that 'Bobby Tables' is a dynamic value that was interpolated. This structural separation is the entire point.
The Template Class
A Template has three key attributes:
| Attribute | Type | Description |
|---|
strings | tuple[str, ...] | The static text parts |
interpolations | tuple[Interpolation, ...] | The dynamic parts |
values | tuple[object, ...] | Just the values from interpolations |
The invariant: there is always exactly one more string than interpolation. For t"Hello, {name}!", strings is ("Hello, ", "!") and interpolations has one entry for name.
The Interpolation Class
Each interpolation captures everything about the expression:
from string.templatelib import Interpolation
# Interpolation fields:
# - value: the evaluated result (e.g., "Bobby Tables")
# - expression: the source code text (e.g., "name")
# - conversion: !s, !r, !a, or None
# - format_spec: the :format part, or ""
This means a t-string like t"{price:.2f}" gives you access to both the raw price value AND the .2f format specification — separately. You can choose to apply the format spec, ignore it, or replace it with your own logic.
Let me put t-strings in historical context, because understanding what came before explains why they exist:
| Era | Syntax | Year | Return Type | Can Intercept Values? |
|---|
| Percent | "Hello %s" % name | 1991 | str | No |
| Template | Template("Hello $name") | 2004 (PEP 292) | str | Yes, but clunky |
| .format() | "Hello {}".format(name) | 2008 (PEP 3101) | str | No |
| f-string | f"Hello {name}" | 2016 (PEP 498) | str | No |
| t-string | t"Hello {name}" | 2025 (PEP 750) | Template | Yes |
The old string.Template class (from 2004) could intercept values — that was its whole purpose. But its syntax was limited: no expressions, no format specs, no arbitrary Python code in the placeholders. T-strings combine the power of f-string syntax with the interceptability of string.Template.
Real Use Case #1: SQL That Can't Be Injected
This is the killer use case. Psycopg 3.3 — the most popular Python PostgreSQL driver — already supports t-strings natively:
import psycopg
conn = psycopg.connect("dbname=mydb")
# OLD WAY: parameterized queries (safe but ugly)
conn.execute("SELECT * FROM users WHERE name = %s AND age > %s", (name, age))
# NEW WAY: t-strings (safe AND readable)
conn.execute(t"SELECT * FROM users WHERE name = {name} AND age > {age}")
The t-string version looks like an f-string — you can read it naturally. But under the hood, psycopg receives a Template object, extracts the static SQL and the dynamic values separately, and generates a properly parameterized query. The values never touch the SQL string directly.
Psycopg's t-string support goes further with format specifiers:
# :i = identifier (table/column name, safely quoted)
# :l = literal (client-side binding)
# :q = query snippet (composable sub-query)
table = "users"
conn.execute(t"SELECT * FROM {table:i} WHERE name = {name}")
# Generates: SELECT * FROM "users" WHERE name = 'Bobby Tables'
# The table name is quoted as an identifier, the value is parameterized
There's also sql-tstring, a standalone library that works with any database driver:
from sql_tstring import sql
query, params = sql(t"SELECT * FROM users WHERE name = {name}")
# query = "SELECT * FROM users WHERE name = ?"
# params = ("Bobby Tables",)
cursor.execute(query, params)
The sql-tstring library even supports dynamic query rewriting — special values like Absent, IsNull, and IsNotNull that transform queries at runtime. And it provides backward compatibility for Python 3.12 and 3.13 through an alternative syntax.
Real Use Case #2: HTML That Can't Be XSSed
Dave Peck, one of the PEP 750 co-authors, built tdom — an HTML templating library powered by t-strings:
from tdom import html
user_input = '<script>alert("xss")</script>'
page = html(t"<div class='greeting'>Hello, {user_input}!</div>")
# Output: <div class='greeting'>Hello, <script>alert("xss")</script>!</div>
The html() function automatically escapes special characters in interpolated values. The static HTML from your source code passes through unchanged. Only the dynamic parts get sanitized. This is structural safety — not "remember to call escape() on every variable."
tdom supports component-style patterns too:
from tdom import html
def card(title, body):
return html(t"""
<div class="card">
<h2>{title}</h2>
<p>{body}</p>
</div>
""")
# Compose them
page = html(t"<main>{card('Hello', user_input)}</main>")
This is Python's answer to JSX. Not identical — tdom is still early (labeled pre-alpha on PyPI) — but the pattern is the same: write your markup inline with your logic, and let the framework handle the safety.
Real Use Case #3: Shell Commands That Can't Be Injected
PEP 787 proposes extending subprocess and shlex to support t-strings:
import subprocess
filename = "my file; rm -rf /"
# DANGEROUS: f-string + shell=True
subprocess.run(f"echo {filename}", shell=True) # Executes rm -rf /
# SAFE: t-string + shell=True (proposed in PEP 787)
subprocess.run(t"echo {filename}", shell=True)
# Properly escapes: echo 'my file; rm -rf /'
PEP 787 is still under discussion (not yet accepted), but it demonstrates the pattern: anywhere you're interpolating untrusted values into a structured format — SQL, HTML, shell commands, regex — t-strings give you a safe default.
Building Your Own T-String Processor
This is where t-strings get really powerful. Writing a custom processor is just a function that accepts a Template:
from string.templatelib import Template, Interpolation
def upper_values(template: Template) -> str:
"""Process a t-string, uppercasing all interpolated values."""
parts = []
for item in template:
if isinstance(item, Interpolation):
parts.append(str(item.value).upper())
else:
parts.append(item)
return "".join(parts)
name = "world"
result = upper_values(t"hello, {name}!")
# result: "hello, WORLD!"
The static text stays lowercase. Only the interpolated values get uppercased. You couldn't do this with f-strings because by the time you have the string, you've lost the information about which parts were interpolated.
Here's a more practical example — a regex builder that auto-escapes interpolated values:
import re
from string.templatelib import Template, Interpolation
def safe_regex(template: Template) -> re.Pattern:
"""Build a regex, escaping interpolated values."""
parts = []
for item in template:
if isinstance(item, Interpolation):
parts.append(re.escape(str(item.value)))
else:
parts.append(item)
return re.compile("".join(parts))
user_search = "price (USD)"
pattern = safe_regex(t".*{user_search}.*")
# Compiles: .*price\ \(USD\).*
# The parentheses are escaped — no regex injection
Or a logging formatter that redacts sensitive fields:
from string.templatelib import Template, Interpolation
SENSITIVE_FIELDS = {"password", "ssn", "credit_card"}
def safe_log(template: Template) -> str:
parts = []
for item in template:
if isinstance(item, Interpolation) and item.expression in SENSITIVE_FIELDS:
parts.append("***REDACTED***")
else:
parts.append(str(item.value) if isinstance(item, Interpolation) else item)
return "".join(parts)
password = "hunter2"
print(safe_log(t"User login attempt with password={password}"))
# Output: "User login attempt with password=***REDACTED***"
The expression attribute gives you the variable name from the source code. That's metadata you can use for policy enforcement, logging controls, or domain-specific validation — none of which is possible with f-strings.
What JavaScript Got Right (And Python Learned From)
If this sounds familiar, it should. JavaScript has had tagged templates since ES6 (2015):
function safe(strings, ...values) {
return strings.reduce((result, str, i) =>
result + str + (values[i] ? escape(values[i]) : ''), '');
}
const name = '<script>alert("xss")</script>';
const html = safe`Hello, ${name}!`;
// Hello, <script>alert("xss")</script>!
Python's t-strings are the Pythonic parallel to JavaScript's tagged templates. The concept is the same: intercept string interpolation before it produces a final string. But Python's implementation is more structured — you get a proper Template object with typed Interpolation instances, not just raw arrays of strings and values.
The Ecosystem Is Already Moving
T-strings shipped in October 2025. Six months later, the ecosystem response has been strong:
There's also awesome-t-strings, a curated list of t-string resources, libraries, and examples maintained by the community.
The Django discussion is particularly interesting. If Django's ORM adds t-string support, you'd write queries like:
# Hypothetical Django t-string ORM query
users = User.objects.raw(t"SELECT * FROM users WHERE name = {name}")
Instead of the current approach with %s placeholders and separate parameter tuples. The safety guarantee would come from the ORM, not from developer discipline.
When to Use T-Strings vs F-Strings
T-strings don't replace f-strings. They solve a different problem.
| Use Case | Use F-String | Use T-String |
|---|
| Log messages | Yes | Only if redacting sensitive data |
| Display strings (print, UI) | Yes | No |
| SQL queries | Never | Yes |
| HTML generation | Never | Yes |
| Shell commands | Never | Yes (with PEP 787) |
| API request bodies | Depends | Yes, if untrusted input |
| Regex with user input | Never | Yes |
| String concatenation | Yes | No |
| i18n / localization | No (use gettext) | Potential future use case |
The rule is simple: if the interpolated values come from your own code and the result is just a string for display, use an f-string. If the interpolated values might come from user input, or the result is being consumed by a parser (SQL, HTML, shell), use a t-string.
Common Misconceptions
"T-strings are just f-strings that don't evaluate immediately."
No. F-strings evaluate their expressions immediately AND combine them into a string immediately. T-strings evaluate their expressions immediately BUT don't combine them. The expressions ARE evaluated when the t-string line executes — t"{expensive_function()}" calls expensive_function() right away. The difference is that the result is stored as a structured Template, not concatenated into a str.
"T-strings are slower than f-strings."
For simple string formatting, yes — creating a Template object has overhead compared to producing a str directly. But the comparison is wrong. The alternative to a t-string isn't an f-string. It's an f-string plus manual escaping, parameterized query construction, or template engine invocation. T-strings eliminate boilerplate, not performance.
"T-strings make f-strings obsolete."
Absolutely not. F-strings are the right tool for 90% of string formatting tasks. T-strings are for the 10% where you need to process the interpolated values before combining them. The two features complement each other — they even share the same syntax.
"It's confusing having t and f look so similar."
This is actually a valid concern. t"..." and f"..." differ by one character but produce completely different types. If you accidentally type t instead of f (or vice versa), you'll get a Template where you expected a str, which will surface as a TypeError downstream. The Hacker News discussion raised this point. In practice, type checkers and the immediate TypeError will catch it — but it's a real usability trade-off.
Getting Started With T-Strings Today
Here's what you need:
- Install Python 3.14 (or use pyenv):
pyenv install 3.14.0
pyenv local 3.14.0
python --version # Python 3.14.0
- Write your first t-string:
from string.templatelib import Template, Interpolation
name = "world"
template = t"Hello, {name}!"
# Inspect the structure
print(template.strings) # ('Hello, ', '!')
print(template.values) # ('world',)
print(template.interpolations) # (Interpolation(value='world', ...),)
# Iterate
for part in template:
print(type(part).__name__, repr(part))
# str 'Hello, '
# Interpolation Interpolation(value='world', expression='name', ...)
# str '!'
- Write a processor:
def render(template: Template) -> str:
"""Simple processor that just joins everything."""
return "".join(
str(item.value) if isinstance(item, Interpolation) else item
for item in template
)
print(render(t"Hello, {name}!")) # Hello, world!
- Try sql-tstring for immediate practical value:
pip install sql-tstring
from sql_tstring import sql
user_id = 42
query, params = sql(t"SELECT * FROM users WHERE id = {user_id}")
# query = "SELECT * FROM users WHERE id = ?"
# params = (42,)
What I Actually Think
T-strings are the most important Python feature since f-strings. And I think they'll end up being more impactful.
F-strings made string formatting pleasant. T-strings make string formatting safe. That's a bigger deal.
The history of software security is littered with injection vulnerabilities — SQL injection, XSS, command injection — that exist because languages make it easier to build strings unsafely than safely. PHP had this problem. JavaScript had this problem. Python has had this problem for decades. Every "just use parameterized queries" lecture, every OWASP training, every code review comment — they're all patches on the fundamental issue that string interpolation and string rendering are coupled.
T-strings decouple them. For the first time in Python, you can write t"SELECT * FROM users WHERE id = {user_id}" and it's not a SQL injection vulnerability. Not because you remembered to parameterize. Not because a linter caught it. Because the language itself preserves the structure.
The ecosystem adoption in six months tells the story. Psycopg 3.3 ships with native support. sql-tstring works with any database. tdom is bringing JSX-style HTML templating to Python. PEP 787 is bringing safe subprocess calls. This isn't a feature looking for a use case — the use cases have been waiting for this feature for years.
The one concern I have is the t vs f visual similarity. Accidentally writing f"..." when you meant t"..." means your SQL query is an injection vulnerability again, silently. Type annotations and IDE support will help, but it would be nice to see linters specifically flag f-strings used in SQL/HTML contexts with a "did you mean t-string?" suggestion.
But that's a minor quibble. T-strings are Python getting string safety right, twenty years after the problem was first understood. They deserve to be adopted everywhere that untrusted input meets structured output.
Start using them. Your future self (and your security team) will thank you.
Sources
- PEP 750 — Template Strings
- Python 3.14: Template Strings (T-Strings) — Real Python
- string.templatelib — Python 3.14 Documentation
- Python's New T-Strings — Dave Peck
- Introducing tdom: HTML Templating with T-Strings — Dave Peck
- Template Strings Accepted for Python 3.14 — LWN.net
- PEP 787 — Safer Subprocess Usage Using T-Strings
- Template String Queries — psycopg 3.3 Documentation
- sql-tstring — PyPI
- sql-tstring — GitHub
- tdom — GitHub
- PEP 498 — Literal String Interpolation (F-Strings)
- PEP 745 — Python 3.14 Release Schedule
- Python 3.14 Release — Python.org
- Supporting T-Strings from Python 3.14 — Django Forum
- Hacker News Discussion — Python's New T-Strings
- SQL-tString: Python's New Approach to SQL Injection Prevention — BigGo
- T-Strings: Python's Fifth String Formatting Technique — Python Morsels
- How to Use Template Strings in Python 3.14 — InfoWorld
- awesome-t-strings — GitHub