Ismat Samadov
  • Tags
  • About
16 min read/1 views

Python 3.14 T-Strings Will Change How You Write Python Forever

T-strings return a Template object, not a string. That one change enables SQL injection prevention, XSS-safe HTML, and shell safety built into the language.

PythonSecurityProgrammingSQL

Related Articles

OWASP Top 10 for LLM Applications: The Attacks Your AI App Isn't Ready For

15 min read

Testing LLM Applications Is Nothing Like Testing Regular Software — Here's What Actually Works

14 min read

Rate Limiting, Circuit Breakers, and Backpressure: The Three Patterns That Keep Distributed Systems Alive

18 min read

Enjoyed this article?

Get new posts delivered to your inbox. No spam, unsubscribe anytime.

On this page

  • Why Python Needed a Sixth Way to Format Strings
  • How T-Strings Actually Work
  • The Template Class
  • The Interpolation Class
  • The Five Eras of Python String Formatting
  • Real Use Case #1: SQL That Can't Be Injected
  • Real Use Case #2: HTML That Can't Be XSSed
  • Real Use Case #3: Shell Commands That Can't Be Injected
  • Building Your Own T-String Processor
  • What JavaScript Got Right (And Python Learned From)
  • The Ecosystem Is Already Moving
  • When to Use T-Strings vs F-Strings
  • Common Misconceptions
  • Getting Started With T-Strings Today
  • What I Actually Think
  • Sources

© 2026 Ismat Samadov

RSS

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.


Why Python Needed a Sixth Way to Format Strings

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:

AttributeTypeDescription
stringstuple[str, ...]The static text parts
interpolationstuple[Interpolation, ...]The dynamic parts
valuestuple[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.


The Five Eras of Python String Formatting

Let me put t-strings in historical context, because understanding what came before explains why they exist:

EraSyntaxYearReturn TypeCan Intercept Values?
Percent"Hello %s" % name1991strNo
TemplateTemplate("Hello $name")2004 (PEP 292)strYes, but clunky
.format()"Hello {}".format(name)2008 (PEP 3101)strNo
f-stringf"Hello {name}"2016 (PEP 498)strNo
t-stringt"Hello {name}"2025 (PEP 750)TemplateYes

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, &lt;script&gt;alert("xss")&lt;/script&gt;!</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, &lt;script&gt;alert("xss")&lt;/script&gt;!

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:

LibraryStatusWhat It Does
psycopg 3.3ReleasedNative t-string SQL queries for PostgreSQL
sql-tstringReleasedDatabase-agnostic t-string SQL (works with 3.12+)
tdomPre-alphaHTML templating with automatic XSS escaping
DjangoDiscussionForum thread on t-string ORM integration
PEP 787Proposedsubprocess/shlex t-string support

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 CaseUse F-StringUse T-String
Log messagesYesOnly if redacting sensitive data
Display strings (print, UI)YesNo
SQL queriesNeverYes
HTML generationNeverYes
Shell commandsNeverYes (with PEP 787)
API request bodiesDependsYes, if untrusted input
Regex with user inputNeverYes
String concatenationYesNo
i18n / localizationNo (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:

  1. Install Python 3.14 (or use pyenv):
pyenv install 3.14.0
pyenv local 3.14.0
python --version  # Python 3.14.0
  1. 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 '!'
  1. 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!
  1. 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

  1. PEP 750 — Template Strings
  2. Python 3.14: Template Strings (T-Strings) — Real Python
  3. string.templatelib — Python 3.14 Documentation
  4. Python's New T-Strings — Dave Peck
  5. Introducing tdom: HTML Templating with T-Strings — Dave Peck
  6. Template Strings Accepted for Python 3.14 — LWN.net
  7. PEP 787 — Safer Subprocess Usage Using T-Strings
  8. Template String Queries — psycopg 3.3 Documentation
  9. sql-tstring — PyPI
  10. sql-tstring — GitHub
  11. tdom — GitHub
  12. PEP 498 — Literal String Interpolation (F-Strings)
  13. PEP 745 — Python 3.14 Release Schedule
  14. Python 3.14 Release — Python.org
  15. Supporting T-Strings from Python 3.14 — Django Forum
  16. Hacker News Discussion — Python's New T-Strings
  17. SQL-tString: Python's New Approach to SQL Injection Prevention — BigGo
  18. T-Strings: Python's Fifth String Formatting Technique — Python Morsels
  19. How to Use Template Strings in Python 3.14 — InfoWorld
  20. awesome-t-strings — GitHub