Python t-Strings: The Complete Guide to Template String Interpolation

Python 3.14 ships with a feature that fundamentally changes how string interpolation can work in the language: t-strings, introduced in PEP 750. They look almost identical to f-strings on the surface, but they do something completely different under the hood—and that difference matters enormously for anyone building web applications, database queries, logging pipelines, or any system where untrusted data meets a string template.

String interpolation in Python has a history. The language started with % formatting borrowed from C, moved to str.format() for more power and readability, and then in Python 3.6 arrived f-strings—which immediately became the most popular way to embed values in strings. F-strings are concise, fast, and readable. They also, it turns out, have a structural limitation that you can only work around, never fix—until now.

The Problem F-Strings Cannot Solve

F-strings collapse everything into a plain string the moment they are evaluated. That is exactly what you want when you are printing a log line or formatting a number for display. It is exactly what you do not want when you need to inspect, escape, or transform one or more of the interpolated values before the final string is assembled.

Consider a developer building an HTML page that includes user-supplied content:

# Dangerous: f-strings give no opportunity to escape
username = "<script>alert('xss')</script>"
html = f"<p>Welcome, {username}</p>"
# html is now a live XSS payload, already fully formed

The f-string has already run. The script tag is embedded. There is no intercept point. Developers work around this today by calling an escaping function manually before writing the f-string, or by switching to a dedicated templating library. Both approaches require discipline and have failure modes. PEP 750 describes this pattern precisely: f-strings provide no mechanism to intercept and transform interpolated values before they are combined into a final string, which means incautious use can lead to security vulnerabilities including SQL injection and cross-site scripting.

"f-strings provide no way to intercept and transform interpolated values before they are combined into a final string. As a result, incautious use of f-strings can lead to security vulnerabilities." — PEP 750, python.org

The same problem applies to SQL. A developer who writes f"SELECT * FROM users WHERE id = {user_id}" has written a SQL injection waiting to happen. Parameterized queries are the correct solution, but f-strings make the wrong approach feel frictionless, which is a design trap.

What t-Strings Actually Are

A t-string uses a t (or T) prefix in place of the f prefix used by f-strings. The syntax for the literal itself is otherwise identical: you can use single quotes, double quotes, triple-quoted variants, and curly-brace interpolations with the same expression support as f-strings.

name = "Pythonista"
day = "Friday"

# f-string: evaluates immediately to str
greeting_f = f"Hello, {name}! Today is {day}."
print(type(greeting_f))   # <class 'str'>

# t-string: evaluates to Template, not str
greeting_t = t"Hello, {name}! Today is {day}."
print(type(greeting_t))   # <class 'string.templatelib.Template'>

The single most important fact about t-strings: they do not produce a str. They produce a Template instance from the new standard-library module string.templatelib, added in Python 3.14. According to the official Python documentation for that module, the Template class gives access to the static and interpolated parts of a string before they are combined.

Version Note

t-strings are available starting with Python 3.14, released in October 2025. The feature is defined in PEP 750 and lives in the string.templatelib module. You cannot use them in Python 3.13 or earlier.

This is a deliberate design choice with significant implications. Because the Template type does not provide a specialized __str__() implementation, you cannot accidentally convert a t-string to a plain string by passing it to print() or str() and expect a formatted result. The PEP explains this explicitly: Template instances are intended to be used by template processing code, which may return a string or any other type. There is no canonical way to convert a Template to a string.

That constraint is a feature. It forces you to pass the Template through a processor function that you control, where you can apply escaping, validation, or any other logic before the final output is produced.

The Template and Interpolation Types

Two new types ship with Python 3.14 as part of this feature: Template and Interpolation, both found in string.templatelib. Understanding their structure is essential to writing a processor that uses them correctly.

The Template Type

A Template instance holds the parsed contents of a t-string as separate tuples. It is immutable, meaning its attributes cannot be reassigned after creation. The three key attributes are:

  • .strings — A tuple of the static (literal) string portions between the interpolations.
  • .interpolations — A tuple of Interpolation objects, one per {...} expression.
  • .values — A convenience tuple of the evaluated values from each interpolation, equivalent to tuple(i.value for i in template.interpolations).
from string.templatelib import Template

cheese = "Camembert"
template = t"Ah! We do have {cheese}."

print(template.strings)        # ('Ah! We do have ', '.')
print(template.values)         # ('Camembert',)
print(template.interpolations) # (Interpolation('Camembert', 'cheese', None, ''),)

One structural rule to memorize: the strings tuple always has exactly one more element than the interpolations and values tuples. The interpolations conceptually sit between the strings. This holds even for edge cases:

# Empty t-string
t"".strings        # ('',)
t"".values         # ()

# t-string that starts with an interpolation
x = 42
t"{x} items".strings   # ('', ' items')
t"{x} items".values    # (42,)

The official Python documentation describes this interleaving clearly: the strings tuple is never empty, and always contains one more string than the interpolations and values tuples.

The Interpolation Type

Each Interpolation object represents a single {...} expression in the t-string. It carries four attributes:

  • .value — The evaluated result of the expression. This is the actual Python object, not a string representation of it.
  • .expression — A string containing the source text of the expression as written in the t-string literal (e.g., "cheese", "user.name", or "len(items) * 2").
  • .conversion — One of 'r', 's', 'a', or None, corresponding to the !r, !s, and !a conversion flags inherited from f-string syntax.
  • .format_spec — A string containing the format specifier if one was provided (e.g., ".2f"), or an empty string if none was given.
pi = 3.14159
tmpl = t"Pi is approximately {pi:.2f}"

interp = tmpl.interpolations[0]
print(interp.value)       # 3.14159  (the actual float)
print(interp.expression)  # 'pi'
print(interp.conversion)  # None
print(interp.format_spec) # '.2f'
Pro Tip

The .expression attribute is uniquely powerful. It gives your processor the source text of the expression as the developer wrote it—not just its evaluated value. This means a processor can include the expression name in error messages, log which variables were interpolated, or make context-sensitive decisions based on where the data came from.

The .conversion attribute warrants a note. PEP 750 discusses whether it adds significant value given that custom format specifiers can achieve the same outcomes. It is present primarily to maintain compatibility with f-string syntax and to allow future extensibility. In practice, it will be None in the vast majority of real-world t-strings.

Template strings are also iterable. Iterating over a Template yields an interleaved sequence of plain strings and Interpolation objects, which is often the most ergonomic way to write a processor:

food = "cheese"
template = t"Tasty {food}!"

for part in template:
    if isinstance(part, str):
        print("Static part:", part)
    else:
        print("Interpolation:", part.value)

# Static part: Tasty
# Interpolation: cheese
# Static part: !

Writing Your Own Template Processor

PEP 750 defines only the syntax and types for t-string literals. It intentionally does not ship built-in processors—no html(), no sql(), nothing ready-made. That is a deliberate design decision: different domains have different escaping rules, different safety requirements, and different output formats. The standard library provides the structured data; you (or a library you choose) provide the logic.

Here is the simplest possible processor, one that mimics f-string behavior by joining all parts together:

from string.templatelib import Template, Interpolation

def render(template: Template) -> str:
    parts = []
    for part in template:
        if isinstance(part, str):
            parts.append(part)
        elif isinstance(part, Interpolation):
            # Apply format_spec if present, otherwise convert to str
            if part.format_spec:
                parts.append(format(part.value, part.format_spec))
            else:
                parts.append(str(part.value))
    return "".join(parts)

name = "world"
result = render(t"Hello, {name}!")
print(result)  # Hello, world!

This is effectively a reimplementation of f-string behavior using t-string infrastructure. The PEP 750 examples repository on GitHub includes exactly this implementation, demonstrating that t-strings are a strict generalization of f-strings: any f-string behavior can be reproduced on top of t-strings, but not the other way around.

Practical Use Cases: HTML, SQL, and Logging

HTML Escaping

The canonical motivation from PEP 750 is HTML generation with automatic escaping. A processor can inspect each interpolated value and escape any that contain HTML-special characters, while passing static string portions through untouched:

from html import escape as html_escape
from string.templatelib import Template, Interpolation

def html(template: Template) -> str:
    parts = []
    for part in template:
        if isinstance(part, str):
            # Static parts are developer-authored: trusted
            parts.append(part)
        elif isinstance(part, Interpolation):
            # Interpolated values are data: escape them
            parts.append(html_escape(str(part.value)))
    return "".join(parts)

user_input = "<script>alert('xss')</script>"
safe_output = html(t"<p>{user_input}</p>")
print(safe_output)
# <p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>

This is the fundamental security guarantee that t-strings enable: static string parts are always developer-authored text, not user data. Interpolated parts are always data. A processor that escapes every Interpolation value and passes every static string through unchanged will, by construction, prevent injection attacks.

Security Boundary

The security guarantee only holds if the processor correctly distinguishes between static strings and interpolations. A processor that calls str(part) on everything without checking isinstance(part, Interpolation) loses this guarantee. Always check the type of each iterated part.

SQL Parameterization

The SQL injection problem is a direct analogy to the HTML case. A t-string-based SQL processor does not embed values into the query string at all—it builds a parameterized query instead:

from string.templatelib import Template, Interpolation

def sql(template: Template):
    """Return (query_string, params_tuple) for use with a DB cursor."""
    query_parts = []
    params = []
    for part in template:
        if isinstance(part, str):
            query_parts.append(part)
        elif isinstance(part, Interpolation):
            query_parts.append("?")   # placeholder for this DB driver
            params.append(part.value)
    return "".join(query_parts), tuple(params)

user_id = 42
query, params = sql(t"SELECT * FROM users WHERE id = {user_id}")
print(query)   # SELECT * FROM users WHERE id = ?
print(params)  # (42,)

# Then: cursor.execute(query, params)

The query string that reaches cursor.execute() never contains the literal value 42. The database driver handles parameterization. A malicious input in user_id—say, "1 OR 1=1"—is passed as a parameter, not spliced into the query, so the attack cannot work.

Structured Logging

Logging is a case where the separation of static structure from dynamic values is useful not for security, but for searchability and observability. A structured log processor can emit both a human-readable message and a machine-readable dictionary of values:

import logging
from string.templatelib import Template, Interpolation

def log_structured(level: int, template: Template) -> None:
    message_parts = []
    context = {}
    for part in template:
        if isinstance(part, str):
            message_parts.append(part)
        elif isinstance(part, Interpolation):
            message_parts.append(str(part.value))
            context[part.expression] = part.value
    message = "".join(message_parts)
    logging.log(level, message, extra={"structured": context})

user = "alice"
request_id = "req-abc-123"
status = 200
log_structured(
    logging.INFO,
    t"Request {request_id} by {user} completed with status {status}"
)
# Logs: "Request req-abc-123 by alice completed with status 200"
# With extra={"structured": {"request_id": "req-abc-123", "user": "alice", "status": 200}}

The .expression attribute on each Interpolation gives the dictionary its keys. The log aggregator now receives a structured event with named fields it can index and filter, without requiring any changes to how the developer writes the log statement.

t-Strings vs. f-Strings: A Precise Comparison

The two features look nearly identical syntactically, which makes it easy to underestimate how different they are semantically. Here is a precise side-by-side comparison of the properties that actually matter.

name = "world"
value = 99.5

# f-string: always produces str, immediately
f_result = f"Hello {name}, score: {value:.1f}"
print(type(f_result))   # <class 'str'>
print(f_result)         # Hello world, score: 99.5

# t-string: always produces Template, never str
t_result = t"Hello {name}, score: {value:.1f}"
print(type(t_result))   # <class 'string.templatelib.Template'>
print(t_result)         # Template(strings=('Hello ', ', score: ', ''), ...)
  • Return type: f-strings always return str. t-strings always return Template.
  • Evaluation: Both evaluate interpolated expressions eagerly, left to right, at the time the literal is encountered. Neither is lazy. If a variable does not exist at that point, you get a NameError.
  • Scope: Both use lexical scope. The expressions inside {} have access to the same local and global variables they would in any other Python expression at that location.
  • Syntax: The syntax inside {} is identical. Format specifiers, conversion flags (!r, !s, !a), nested expressions, and function calls all work in t-strings exactly as they do in f-strings.
  • No __str__: Unlike f-strings, which produce a value that immediately prints and concatenates as you would expect, Template has no custom __str__(). Passing a t-string to print() directly gives you the repr of the object, not a formatted string.
  • Concatenation: Two Template instances can be concatenated using +. However, concatenating a Template with a plain str is not supported. If you need to prepend or append a static string, wrap it in a Template first.
  • Prefixes: t-strings cannot be combined with b (bytes) or u prefixes. They can be combined with r (raw strings) for the static portions.
"While f-strings generate fully formed strings at runtime, t-strings create a specialized template object that keeps static and dynamic parts separated. This separation allows libraries to see exactly which parts are literal text and which parts are code-injected fields." — Talk Python To Me, Episode 505

Things to Know Before You Use Them

No Built-In Processors

As noted earlier, PEP 750 ships the type system but no ready-made processors. The pep750-examples repository on GitHub contains reference implementations of an f-string emulator, an HTML processor, and a format-string converter, all of which are useful as starting points. Third-party libraries are expected to adopt the t-string interface over time, so watch for updates in libraries like Jinja2, SQLAlchemy, and structlog.

Constructing Templates Directly

While literal syntax is the typical way to create a Template, it can also be constructed programmatically using the constructor:

from string.templatelib import Interpolation, Template

cheese = "Camembert"
template = Template(
    "Ah! We do have ",
    Interpolation(cheese, "cheese"),
    "."
)
# Equivalent to: t"Ah! We do have {cheese}."

The constructor accepts strings and Interpolation instances in any order. This is useful when building templates dynamically at runtime rather than from a literal in source code.

Design History: Why Not Tag Functions?

An earlier version of PEP 750 explored an approach inspired by JavaScript tagged template literals, where an arbitrary callable (a "tag function") would precede the string and receive its parts as arguments. This was ultimately rejected. The PEP authors found the approach too complex to implement in full generality, noted that it would have precluded introducing new string prefixes in the future, and concluded that using classes was more straightforward. The t prefix approach is cleaner and more consistent with how Python handles other string prefix letters.

Immutability

Both Template and Interpolation are immutable. Their attributes cannot be reassigned after creation. This matters when designing processors: you cannot modify a Template in-place; you must build new output by iterating and constructing.

When to Use f-Strings vs. t-Strings

F-strings remain the right choice for the vast majority of everyday string formatting: logging debug output, building display strings, formatting numbers, constructing messages where the output is immediately consumed as text and no untrusted data is involved. They are faster to type and their intent is immediately obvious.

T-strings are the right choice when at least one of the following is true: the string will be interpreted by another system (a database, an HTML renderer, a shell), any of the interpolated values could come from user input or external sources, you need to build a reusable templating abstraction, or you need structured access to both the template structure and the interpolated values simultaneously.

Pro Tip

A practical mental test: if you would need to call an escaping or sanitization function on one or more values before embedding them in an f-string, that is a signal that a t-string with an appropriate processor is the architecturally cleaner solution.

Key Takeaways

  1. t-strings return Template, not str: The single most important behavioral difference from f-strings. A t-string is never a finished string; it is structured data waiting to be processed.
  2. The Template type exposes .strings, .interpolations, and .values: These give you precise, separated access to every piece of the literal. The strings tuple always has one more element than interpolations; they interleave.
  3. Interpolation objects carry .value, .expression, .conversion, and .format_spec: The .expression attribute—the source text of the interpolated expression—is uniquely powerful and has no f-string equivalent.
  4. No built-in processors ship with PEP 750: You write your own or use a library. The standard library provides the data structure; the processing logic is your responsibility.
  5. The security guarantee depends on correct processor design: A processor that treats all static string parts as trusted and all interpolated values as untrusted, and applies the appropriate transformation to each, is provably safe against injection for that domain.
  6. Expressions are still evaluated eagerly: Unlike lazy evaluation schemes, t-strings evaluate their interpolated expressions immediately. The difference from f-strings is not when the expressions are evaluated, but what is done with the results.

T-strings represent the most architecturally significant change to Python string handling since f-strings arrived in 3.6. They do not replace f-strings—the two serve different purposes and the right choice depends entirely on context. What t-strings do is close a structural gap that f-strings cannot address: the ability to reason about the boundary between developer-authored text and runtime data before that boundary is erased by string concatenation. For anyone building systems where that boundary matters, they are a significant addition to the language.

Sources: PEP 750 — Template Strings (python.org)string.templatelib — Python 3.14 DocumentationPython 3.14: Template Strings (Real Python)Python's new t-strings (Dave Peck)Talk Python To Me, Episode 505PEP 750 Examples Repository (GitHub)

back to articles