Every few years Python gets a new way to format strings, and every time it feels like the last one we will ever need. The % operator. str.format(). string.Template. F-strings. Each one was strictly better than the last for the common case of "take some values and shove them into a string." But f-strings had a blind spot that none of these approaches ever fixed: the moment an interpolated value hits the string, you have lost all information about what was a literal and what was user input. Python t-strings — shipped in Python 3.14 via PEP 750 — finally close that gap.
I want to walk through why this matters, what the Template object actually looks like, and how people are already building SQL generators, HTML escapers, and domain-specific languages on top of it. By the end you should be able to write your own t-string processor from scratch.
From F-Strings to T-Strings: What Problem Do They Solve?
Consider the most common SQL injection pattern in Python:
username = "\'; DROP TABLE students;--"
query = f"SELECT * FROM students WHERE name = '{username}'"
# Oops: "SELECT * FROM students WHERE name = ''; DROP TABLE students;--'"
The f-string evaluates eagerly and returns a flat str. By the time your database driver sees it, the literal SQL and the untrusted user input are indistinguishable. You cannot un-mix them. Every mitigation for this — parameterized queries, manual escaping, ORM wrappers — works around the problem rather than solving it at the language level.
Template strings look identical to f-strings in syntax. You swap the f prefix for a t prefix. But instead of producing a str, they produce a Template object that keeps the static fragments and the interpolated values separate.
username = "\'; DROP TABLE students;--"
template = t"SELECT * FROM students WHERE name = {username}"
# template is a Template object, NOT a string
The expression {username} is evaluated eagerly — just like in an f-string, the right-hand side runs immediately. But the result is not concatenated into a string. Instead it is stored as a structured Interpolation alongside the surrounding literal pieces. A downstream processor gets to decide how to combine them.
This is the core insight. T-strings separate the description of a string from its assembly. The person who writes t"..." describes what they want. The person who writes the processor decides how values are treated — escaped, parameterized, validated, transformed, or rejected entirely.
PEP 750 was authored by Jim Baker, Guido van Rossum, Dave Peck, and others. The fact that Guido is a co-author is notable; he rarely appears on modern PEPs. The proposal was accepted by the Python Steering Council on April 10, 2025 and shipped in Python 3.14 in October 2025. The design draws explicit inspiration from JavaScript's tagged template literals, though the Python version is more structured.
The Template Object: Anatomy of a T-String
The Template type lives in the new string.templatelib module alongside two companions: Interpolation and a convert() helper function. Here is what you get when you create a template:
from string.templatelib import Template, Interpolation
name = "Alice"
age = 30
template = t"Hello, {name}! You are {age} years old."
The template object exposes three key properties:
template.strings — a tuple[str, ...] of the static literal fragments. In this case: ("Hello, ", "! You are ", " years old."). There are always N+1 strings for N interpolations, with empty strings at boundaries if needed.
template.interpolations — a tuple[Interpolation, ...] of the interpolated parts. Each Interpolation carries:
value: the evaluated result of the expression (e.g. "Alice", 30)
expression: the source text of the expression (e.g. "name", "age")
conversion: one of "a", "r", "s", or None (corresponding to !a, !r, !s)
format_spec: the format specifier string (e.g. ".2f", "^10", or empty "")
template.values — a convenience tuple[object, ...] of just the values from each interpolation.
The Template is immutable. You cannot reassign its attributes after creation. You can concatenate two Templates with +, which produces a new Template. And you can iterate over it: iteration yields the static strings (skipping empty ones) and Interpolation objects interleaved in order.
Here is a detail that matters enormously for performance: the strings tuple is interned by the compiler. If you call t"SELECT * FROM users WHERE id = {user_id}" in a loop a million times, the strings tuple is the same object every time. Only the interpolation values change. This means you can use template.strings as a dictionary key or cache key — for instance, to cache a parsed SQL prepared statement and reuse it across calls with different parameters. This is not an accident. It is a deliberate design choice for exactly this use case.
One more thing that catches people off guard: Template has no __str__() method. If you try str(template) or print(template), you get a TypeError. This is intentional. The PEP authors made a conscious decision to prevent accidental stringification. If you want a string, you must explicitly process the template through a function that decides how to handle the interpolated values. There is no default behavior. This is a security design choice, not an oversight.
Building a Safe SQL Generator (with Real Code)
Let me walk through a complete SQL parameterization function. The goal: take a t-string that looks like a natural query and produce a parameterized SQL string plus a tuple of values, ready to hand to a database driver.
from string.templatelib import Template, Interpolation
def sql(template: Template) -> tuple[str, tuple]:
"""Convert a t-string into a parameterized SQL query.
Returns (query_string, params) suitable for cursor.execute().
"""
parts = []
params = []
for item in template:
if isinstance(item, str):
# Static SQL fragment — trusted, pass through directly
parts.append(item)
elif isinstance(item, Interpolation):
# User-provided value — replace with placeholder
parts.append("?")
params.append(item.value)
return "".join(parts), tuple(params)
# Usage
username = "\'; DROP TABLE students;--"
query, params = sql(t"SELECT * FROM students WHERE name = {username}")
print(query) # SELECT * FROM students WHERE name = ?
print(params) # ("\'; DROP TABLE students;--",)
The malicious input never touches the SQL string. It goes into the params tuple, which the database driver treats as a literal value — not as SQL syntax. This is the same parameterized query pattern that security experts have recommended for decades, but now it reads like a natural f-string instead of a clunky cursor.execute("... WHERE name = ?", (username,)).
Notice what happens if you try to use this with a regular f-string by accident: it would not compile, because sql() expects a Template argument. The type system catches the mistake.
And here is the caching trick I mentioned. Because template.strings is interned, you can cache prepared statements:
_prepared_cache: dict[tuple[str, ...], str] = {}
def sql_cached(template: Template) -> tuple[str, tuple]:
key = template.strings
if key not in _prepared_cache:
parts = []
for item in template:
if isinstance(item, str):
parts.append(item)
else:
parts.append("?")
_prepared_cache[key] = "".join(parts)
return _prepared_cache[key], template.values
On the first call, this builds the query string. On every subsequent call with the same template shape, it is a dictionary lookup. The values change; the structure does not.
HTML Escaping and Beyond
The SQL case is clean because we can offload value handling to the database driver's parameterization. HTML is different — we need to inline the values but escape them. Same pattern, different processor:
import html as html_mod
from string.templatelib import Template, Interpolation
def safe_html(template: Template) -> str:
"""Render a t-string as HTML with automatic XSS escaping."""
parts = []
for item in template:
if isinstance(item, str):
# Static HTML — trusted, written by the developer
parts.append(item)
elif isinstance(item, Interpolation):
# Dynamic value — escape to prevent XSS
parts.append(html_mod.escape(str(item.value)))
return "".join(parts)
# Usage
user_input = '<script>alert("xss")</script>'
result = safe_html(t"<div class='comment'>{user_input}</div>")
print(result)
# <div class='comment'><script>alert("xss")</script></div>
The script tag is neutralized. The static HTML passes through untouched. This is the exact pattern that Django's format_html() has tried to provide for years, but now it lives at the language level.
You might wonder: why not just use conversion flags and format specs? After all, f-strings support !r and :.2f. T-strings support the same syntax — t"{value!r:.2f}" is valid — but here is the key difference: conversions and format specs are not automatically applied. They are stored on the Interpolation object for the processor to examine. The processor decides whether to honor them, ignore them, or interpret them as something entirely different.
This is what makes t-strings a platform for DSLs rather than just a safer f-string. The convert() helper from string.templatelib exists so that you can apply standard conversion semantics if you want to:
from string.templatelib import Template, Interpolation, convert
def f(template: Template) -> str:
"""Reimplement f-string behavior using t-strings."""
parts = []
for item in template:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, Interpolation):
value = convert(item.value, item.conversion)
parts.append(format(value, item.format_spec))
return "".join(parts)
pi = 3.14159265
result = f(t"Pi is approximately {pi:.4f}")
print(result) # Pi is approximately 3.1416
This seven-line function is a complete reimplementation of f-string semantics. It applies !s, !r, !a conversions via convert(), then runs format() with the spec. You could use this as a starting point and add logging, validation, or transformation on top.
The Ecosystem: Who's Already Using T-Strings?
Python 3.14 shipped in October 2025, and the ecosystem moved fast.
psycopg3 (version 3.3) was the first major database library to ship native t-string support. You can write queries directly:
cursor.execute(t"SELECT * FROM users WHERE id = {user_id}")
Psycopg processes the template internally, extracting values into parameters. But it goes further: it defines custom format specifiers. Use :i to mark an interpolation as a SQL identifier (table name, column name) rather than a value, and :s, :b, or :t to control parameter encoding format. This is exactly the kind of DSL extension that t-strings were designed to enable — the format spec field becomes a channel for domain-specific metadata.
sql-tstring, by Phil Jones, takes a different approach. Instead of just parameterizing values, it supports query rewriting through sentinel values. Import Absent from the library and use it as a parameter value — sql-tstring will remove the entire clause containing that parameter from the query. Import IsNull or IsNotNull and the library rewrites WHERE x = {param} into WHERE x IS NULL or WHERE x IS NOT NULL, because x = NULL is always false in SQL. This turns t-strings into a dynamic query builder, not just a parameterizer.
tdom, by Dave Peck (one of the PEP 750 authors), is an HTML templating library built on t-strings. You write HTML in t-strings, pass them to tdom.html(), and get back a tree of DOM nodes with automatic XSS escaping. It handles attributes, style objects, class lists, and nested templates. It feels like JSX, but in Python.
Django has an active discussion thread on the Django Forum about integrating t-strings into format_html() and potentially into raw SQL support. Nothing has shipped yet, but the interest signals where the framework ecosystem is heading.
Patterns That Unlock T-Strings' Real Power
Beyond the obvious SQL and HTML cases, a few patterns emerge that I think will define how t-strings get used in practice.
Pattern 1: Lazy evaluation via lambda wrapping. T-strings evaluate interpolations eagerly — {expensive_function()} runs immediately. But you can wrap expensive computations in lambdas and have your processor call them only when needed:
from string.templatelib import Template, Interpolation
def lazy_log(template: Template, level: int, threshold: int) -> str | None:
if level < threshold:
return None # Skip expensive string assembly entirely
parts = []
for item in template:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, Interpolation):
value = item.value
if callable(value):
value = value() # Call the lambda only if we need the result
parts.append(str(value))
return "".join(parts)
This is a pattern that logging frameworks have wanted for years. Python's logging module already defers string formatting, but t-strings make the mechanism explicit and extensible.
Pattern 2: Structural validation. Because you have access to both the static template and the interpolated values, you can validate the structure of the output before producing it:
from string.templatelib import Template, Interpolation
def validated_path(template: Template) -> str:
"""Build a file path, rejecting directory traversal attempts."""
parts = []
for item in template:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, Interpolation):
segment = str(item.value)
if ".." in segment or "/" in segment or "\\" in segment:
raise ValueError(
f"Path segment {item.expression!r} contains "
f"illegal characters: {segment!r}"
)
parts.append(segment)
return "".join(parts)
filename = "../../etc/passwd"
try:
path = validated_path(t"/uploads/{filename}")
except ValueError as e:
print(e)
# Path segment 'filename' contains illegal characters: '../../etc/passwd'
The processor rejects traversal attacks at the point of string assembly. The expression field on the Interpolation even tells you which variable caused the problem — useful for error messages and audit logs.
Pattern 3: The rt raw variant. Just as f-strings have a raw variant rf"..." that suppresses backslash escape processing, t-strings have rt"..." (or tr"..."). The static string portions are treated as raw strings — backslashes are literal. This is useful when your template contains regex patterns or Windows file paths that you don't want Python to interpret.
Where This Leaves Python's String Story
Python now has five string formatting mechanisms. That sounds like too many, and in some sense it is. But each one occupies a genuinely different niche. %-formatting is legacy. str.format() is for dynamic format strings. string.Template is for untrusted template patterns (think i18n). F-strings are for the everyday case where you just want a string. And t-strings are for when you need to control how that string gets built.
The no-__str__ design is what makes me most optimistic about t-strings. It is a rare case of a language feature that makes the wrong thing hard instead of making the right thing easy. You cannot accidentally stringify a template and bypass your safety logic. You have to explicitly choose a processing path. This is a meaningful shift in how Python thinks about string interpolation — not just as a convenience feature, but as a security boundary.
If you are building anything that takes user input and embeds it in a structured output — SQL, HTML, shell commands, log messages, API calls — t-strings give you a tool that works with the language rather than around it. The syntax is familiar. The semantics are sound. And the ecosystem is already building on it.
I expect that within a year or two, t"..." will be as natural to reach for as f"..." is today — not as a replacement, but as the version you use when the values passing through your strings actually matter.