Python 3.14 Template Strings: Safer String Composition with t-strings

F-strings arrived in Python 3.6 and quickly became the most beloved feature in the language. They’re fast, readable, and concise. But they have one fundamental limitation: they eagerly evaluate to a string. The moment Python processes an f-string, the interpolated values are baked in, and there’s no opportunity to inspect, validate, or transform them before they’re combined.

Python 3.14 changes this with template strings (t-strings), specified in PEP 750. T-strings use a t prefix instead of f, and instead of evaluating to str, they produce a Template object that gives you access to the static text and interpolated values before they’re combined. This opens the door to safe SQL query construction, automatic HTML escaping, structured logging, and much more — all without giving up the familiar f-string syntax.

The Core Idea: Templates, Not Strings

A t-string literal looks almost identical to an f-string:

from string.templatelib import Template

name = "World"
template = t"Hello {name}"
assert isinstance(template, Template)

The difference is what you get back. A Template is an immutable type with three key attributes:

template = t"We have {cheese} from {country}"

# Static text segments (always one more than interpolations)
template.strings       # ('We have ', ' from ', '')

# Interpolation objects with metadata
template.interpolations
# (Interpolation('Camembert', 'cheese', None, ''),
#  Interpolation('France', 'country', None, ''))

# Shortcut for interpolated values only
template.values        # ('Camembert', 'France')

Each Interpolation carries four fields: the evaluated value, the original expression text, an optional conversion flag ('a', 'r', 's', or None), and a format_spec string. This metadata lets processing functions decide how to render each value without guessing.

Why This Matters: Safe String Composition

The most compelling use case is preventing injection attacks. With f-strings, developers routinely build SQL queries like this:

# DANGEROUS: f-string SQL injection vector
query = f"SELECT * FROM users WHERE name = '{user_input}'"

This compiles into a plain string with no boundaries between code and data. T-strings let you build a query builder that separates them automatically:

import sqlite3
from string.templatelib import Template, Interpolation

def sql(template: Template) -> tuple[str, tuple]:
    """Convert a t-string into a parameterized query."""
    parts = []
    params = []
    for item in template:
        if isinstance(item, Interpolation):
            parts.append("?")
            params.append(item.value)
        else:
            parts.append(item)
    query = "".join(parts)
    return query, tuple(params)

# Now SQL injection is structurally impossible
user_input = "Robert'); DROP TABLE users;--"
query, params = sql(t"SELECT * FROM users WHERE name = {user_input}")
# query: "SELECT * FROM users WHERE name = ?"
# params: ("Robert'); DROP TABLE users;--",)

conn = sqlite3.connect("app.db")
conn.execute(query, params)  # Safe: value goes through parameter binding

The sql() function never sees the raw combined string. It processes the template structurally — static SQL fragments go into the query, interpolated values go into the parameter tuple. There’s no way for user input to escape into the SQL syntax because it’s never stringified into the query.

HTML Escaping Without Thinking

The same pattern works for HTML. A simple html() processor can automatically escape all interpolated content while leaving the surrounding markup untouched:

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

def html(template: Template) -> str:
    """Render a t-string as safe HTML."""
    parts = []
    for item in template:
        match item:
            case str() as s:
                parts.append(s)
            case Interpolation(value, _, conversion, _):
                if conversion == "s":
                    value = str(value)
                parts.append(escape(str(value)))
    return "".join(parts)

evil = "<script>alert('xss')</script>"
result = html(t"<div>{evil}</div>")
# '
<script>alert(\'xss\')</script>
'

Pattern matching makes this clean. The Interpolation type supports positional deconstruction with match statements, so you can extract exactly the fields you need and ignore the rest.

Beyond Security: Structured Logging

T-strings shine wherever you want both human-readable and machine-parseable output from a single expression. Consider structured logging:

import json
from string.templatelib import Template, Interpolation

def log_info(template: Template) -> None:
    """Log human-readable message + structured fields."""
    text_parts = []
    fields = {}
    for item in template:
        if isinstance(item, Interpolation):
            text_parts.append(str(item.value))
            fields[item.expression] = item.value
        else:
            text_parts.append(item)

    message = "".join(text_parts)
    fields_json = json.dumps(fields, default=str)
    print(f"[INFO] {message} | {fields_json}")

user_id = 42
action = "login"
log_info(t"User {user_id} performed {action}")
# [INFO] User 42 performed login | {"user_id": 42, "action": "login"}

One t-string gives you both the human message and the structured key-value pairs. The expression attribute on each interpolation provides the variable name, so you don’t need to repeat it in a separate dictionary.

The convert() Helper

The string.templatelib module includes a convert() function that applies standard f-string conversion semantics. Instead of reimplementing the conversion logic yourself, use it directly:

from string.templatelib import convert

convert("hello", None)   # 'hello'  (unchanged)
convert("hello", "r")    # "'hello'"
convert("hello", "s")    # 'hello'
convert("hello", "a")    # "'hello'"

This is useful when writing template processors that should respect f-string conversion conventions. Rather than branching on conversion codes yourself, delegate to convert() for consistency.

What About Concatenation?

Templates support + concatenation with other Template instances. Implicit concatenation (adjacent t-string literals) also works, matching Python’s string behavior:

name = "World"
combined = t"Hello " + t"{name}"
assert combined.strings == ("Hello ", "")
assert combined.values == ("World",)

# Implicit concatenation also works
implicit = t"Hello " t"{name}"
assert isinstance(implicit, Template)

However, you cannot concatenate a Template with a plain str. This is a deliberate design choice — it’s ambiguous whether the string should be treated as static text or as a dynamic value. If you need to combine them, wrap the string explicitly:

from string.templatelib import Template, Interpolation

# Static string
t"Hello " + Template("World")

# Dynamic value
value = "World"
t"Hello " + Template(Interpolation(value, "value"))

Ecosystem Adoption

Template strings are designed to be extensible. The PEP includes a reference repository of examples, and the community is already building practical processors. Projects like sql-tstring demonstrate how third-party libraries can provide ready-made t-string handlers for common use cases like SQL query construction.

Practical Guidance

When should you reach for t-strings over f-strings?

  • Use f-strings when you just need formatted output with no processing. They’re simpler and slightly faster.
  • Use t-strings when composing strings for external systems — SQL, HTML, shell commands, URLs — where untrusted input could be dangerous if concatenated naively.
  • Use t-strings when you need structured data alongside formatted text, like logging or telemetry.
  • Write template processors as functions that take Template and return any type. There’s no requirement to return a string.

Wrapping Up

Template strings represent a meaningful shift in Python’s string handling philosophy. Rather than adding another formatting option, they introduce a structured representation that separates code from data — the same principle that makes parameterized queries safe and template engines powerful. If you’re building anything that composes strings for external consumption, t-strings give you a type-safe, injection-resistant primitive that works with syntax you already know. The t prefix is a one-character change with outsize impact on correctness.

Leave a Reply

Your email address will not be published. Required fields are marked *