Safe SQL & HTML with Python 3.14 t-strings (PEP 750)

Advanced Python Features Sep 21, 2026

Watch on YouTube

Safe SQL & HTML with Python 3.14 t-strings (PEP 750)

Safe SQL & HTML with Python 3.14 t-strings (PEP 750) — this guide is about t"..." literals that evaluate to string.templatelib.Template, not finished strings. You inspect static parts and interpolations, then write processors that emit parameterized SQL or escaped HTML.

PyInns already covers related-but-different tools: f-string improvements (Python 3.15) and the classic string.Template ($placeholders). For everyday formatting without safety processors, see format() and modern f-strings. Those posts stay useful; this one is exclusively PEP 750.

TL;DR

  • t"Hello {name}" → Template, never a str
  • Inspect .strings, .interpolations, .values; each hole is an Interpolation
  • SQL processor: join static SQL + placeholders; return (sql, params) — never splice raw values into the SQL text
  • HTML processor: escape interpolation values (e.g. html.escape); leave static markup alone
  • Requires Python 3.14+ (syntax; no __future__ backport)

Three “templates” — open with the contrast

FeatureWhat you writeWhat you getBest for
f-strings f"Hi {name}" immediate str logs, display, trusted formatting
string.Template Template("Hi $name") str after .substitute() external/$placeholder templates
t-strings (PEP 750) t"Hi {name}" string.templatelib.Template custom processors (SQL, HTML, DSLs)

f-strings combine everything eagerly — great for readability, dangerous if you embed user input into SQL or HTML. string.Template delays substitution but still produces a plain string with values already inlined. t-strings keep static text and evaluated values separate so your function decides how (or whether) to combine them.

Requirements

python3.14 -c "import string.templatelib; print(string.templatelib.Template)"
# SyntaxError on 3.13 and earlier — t-strings are not backportable

Official API: string.templatelib and PEP 750.

t"" → Template: strings, interpolations, values

A t-string looks like an f-string but the prefix is t. Expressions inside {...} are evaluated eagerly in the current scope (same as f-strings). Conversions and format specs are stored as metadata — processors apply them if they choose.

from string.templatelib import Template, Interpolation

user = "Camembert"
qty = 3
tmpl = t"Order {qty} of {user!s}."

assert isinstance(tmpl, Template)
assert tmpl.strings == ("Order ", " of ", ".")
assert tmpl.values == (3, "Camembert")

# One more string than interpolations; holes sit between the strings:
# strings:  ("Order ",          " of ",           ".")
# values:   (         3,                 "Camembert",   )

for part in tmpl.interpolations:
    assert isinstance(part, Interpolation)
    print(part.value, part.expression, part.conversion, part.format_spec)
# 3 qty None ''
# Camembert user s ''

Empty static chunks appear when interpolations are adjacent (t"{a}{b}" → strings == ("", "", "")). Iteration over a Template yields non-empty strings and Interpolation objects in order — empty strings are omitted from iter(template).

Interpolation fields (official shape)

  • value — evaluated result of the expression
  • expression — source text inside the braces (before !, :, or =)
  • conversion — 'a' / 'r' / 's' or None (unlike f-strings, not applied automatically)
  • format_spec — format string (again: metadata until your processor uses it)
from string.templatelib import convert

price = 12.5
item = t"total={price:.2f}"
interp = item.interpolations[0]
assert interp.expression == "price"
assert interp.format_spec == ".2f"
assert interp.conversion is None

# Mimic f-string conversion when you want it:
assert convert(42, "r") == "42"  # repr
assert convert("x", "s") == "x"  # str
assert convert("x", None) == "x"  # unchanged

Pattern matching works well when writing processors:

from string.templatelib import Template, Interpolation

def describe(template: Template) -> list[str]:
    out: list[str] = []
    for item in template:
        match item:
            case str() as s:
                out.append(f"STATIC:{s!r}")
            case Interpolation(value, expression, conversion, format_spec):
                out.append(
                    f"DYN:{expression}={value!r} "
                    f"conv={conversion} spec={format_spec!r}"
                )
    return out

print(describe(t"id={42:04d}"))
# ['STATIC:\'id=\'', "DYN:42=42 conv=None spec='04d'"]

Processor 1 — parameterized SQL (placeholders + params)

Never interpolate raw values into the SQL string. Emit a static SQL skeleton with placeholders and a parallel params list for the DB-API. That is the entire point of t-strings for SQL.

from __future__ import annotations

from string.templatelib import Template, Interpolation, convert
from typing import Any


def sql(template: Template, *, placeholder: str = "?") -> tuple[str, list[Any]]:
    """Build (sql_text, params) for DB-API execute().

    Static parts are concatenated as-is.
    Each Interpolation becomes one placeholder; its value goes into params.
    Values are NEVER spliced into the SQL string.
    """
    if not isinstance(template, Template):
        raise TypeError("sql() expects a t-string Template")

    parts: list[str] = []
    params: list[Any] = []

    # Walk strings/interpolations in lockstep (official layout)
    for i, static in enumerate(template.strings):
        parts.append(static)
        if i < len(template.interpolations):
            interp = template.interpolations[i]
            value = convert(interp.value, interp.conversion)
            if interp.format_spec:
                # Formatting for display only — still bind the formatted
                # string as a parameter, never as SQL text.
                value = format(value, interp.format_spec)
            parts.append(placeholder)
            params.append(value)

    return "".join(parts), params


# --- usage ---
user_id = 42
# Hostile input: classic injection payload
name = "Robert'); DROP TABLE students;--"

query, params = sql(
    t"SELECT id, email FROM users WHERE id = {user_id} AND name = {name}"
)

assert query == "SELECT id, email FROM users WHERE id = ? AND name = ?"
assert params == [42, "Robert'); DROP TABLE students;--"]

# Hand to the driver (illustrative):
# cursor.execute(query, params)
print(query)
print(params)

Swap placeholder="%" or named-placeholder builders as needed for your driver — the invariant is the same: static SQL from .strings, values only in the params sequence. Named placeholders are a small extension: use interp.expression (sanitized) as the key, still never paste value into the SQL text.

def sql_named(template: Template) -> tuple[str, dict[str, Any]]:
    parts: list[str] = []
    params: dict[str, Any] = {}
    for i, static in enumerate(template.strings):
        parts.append(static)
        if i < len(template.interpolations):
            interp = template.interpolations[i]
            key = "".join(
                ch if ch.isalnum() or ch == "_" else "_"
                for ch in interp.expression
            ) or f"p{i}"
            # Disambiguate repeated expressions
            base = key
            n = 1
            while key in params:
                n += 1
                key = f"{base}_{n}"
            value = convert(interp.value, interp.conversion)
            if interp.format_spec:
                value = format(value, interp.format_spec)
            parts.append(f"%({key})s")  # psycopg2 / MySQLdb style
            params[key] = value
    return "".join(parts), params

Processor 2 — HTML escaping of interpolations

Static markup stays trusted (you wrote it). Interpolation values are treated as untrusted text and escaped. That blocks XSS from user content while keeping tags you authored intact.

from __future__ import annotations

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


def render_html(template: Template) -> str:
    """Escape dynamic parts; keep static HTML verbatim."""
    if not isinstance(template, Template):
        raise TypeError("render_html() expects a t-string Template")

    chunks: list[str] = []
    for item in template:
        match item:
            case str() as s:
                chunks.append(s)
            case Interpolation(value, _, conversion, format_spec):
                value = convert(value, conversion)
                if format_spec:
                    value = format(value, format_spec)
                # Escape AFTER formatting so <, &, quotes cannot break out
                chunks.append(html.escape(str(value), quote=True))
            case _:
                raise TypeError(f"unexpected template part: {item!r}")
    return "".join(chunks)


evil = "<script>alert('xss')</script>"
title = "Cheese & Pickles"
page = render_html(t"<h1>{title}</h1><p>Comment: {evil}</p>")

assert "<script>" not in page
assert "<script>" in page
assert "Cheese & Pickles" in page
print(page)
# <h1>Cheese &amp; Pickles</h1><p>Comment: &lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;</p>

Mark trusted HTML fragments explicitly (e.g. a small wrapper type) if you need nested safe markup — do not silently skip escaping for plain strings. Nested t-strings that already returned escaped str can be passed through a sentinel so you do not double-escape.

from dataclasses import dataclass


@dataclass(frozen=True)
class SafeHTML:
    html: str

    def __str__(self) -> str:
        return self.html


def render_html_safe(template: Template) -> str:
    chunks: list[str] = []
    for item in template:
        match item:
            case str() as s:
                chunks.append(s)
            case Interpolation(SafeHTML(html=_) as safe, *_):
                # Already trusted HTML from a prior render_html call
                chunks.append(str(safe))
            case Interpolation(value, _, conversion, format_spec):
                value = convert(value, conversion)
                if format_spec:
                    value = format(value, format_spec)
                chunks.append(html.escape(str(value), quote=True))
    return "".join(chunks)


inner = SafeHTML(render_html(t"<em>{'nested'}</em>"))
outer = render_html_safe(t"<div>{inner}</div><span>{'<raw>'}</span>")
assert outer == "<div><em>nested</em></div><span><raw></span>"

Putting both together (mini app sketch)

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)")
conn.execute("INSERT INTO notes (body) VALUES (?)", ("hello <b>world</b>",))

note_id = 1
q, p = sql(t"SELECT body FROM notes WHERE id = {note_id}")
row = conn.execute(q, p).fetchone()
body = row[0]

# Safe HTML page: DB value escaped; static structure trusted
print(render_html(t"<article><p>{body}</p></article>"))
# <article><p>hello &lt;b&gt;world&lt;/b&gt;</p></article>

Practical rules (2026)

  • Use f-strings for trusted display; use t-strings when a processor must intervene
  • SQL: placeholders + params always; treat any “string-built SQL” with values as a bug
  • HTML: escape interpolations by default; opt in to trusted HTML via an explicit type
  • Respect conversion / format_spec when they affect presentation; still bind or escape the result
  • Do not confuse PEP 750 with string.Template or with f-string polish in 3.15

Wrap-up

Python 3.14 t-strings give you the f-string ergonomics you already know, plus a first-class Template so libraries can implement safe SQL and HTML without fragile regex or manual splitting. Start with the two processors above, keep static and dynamic parts separate, and link readers who need classic formatting to the f-string / format() / string.Template guides already on PyInns.

Share:
Last updated: September 2026
Built-in Functions in Python 2026 str() in Python 2026: String Conversion + Modern Formatting & Best Practices memoryview with JAX in Python 2026: Zero-Copy NumPy → JAX Array Interop + Efficient ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + GPU Pinning & ML Examples memoryview with TensorFlow in Python 2026: Zero-Copy NumPy → Tensor Interop + ML Examples memoryview with NumPy & PyTorch in Python 2026: Zero-Copy Views, Efficient Slicing & ML Interop Examples memoryview with NumPy in Python 2026: Zero-Copy Views, Efficient Slicing & Real ML Examples memoryview() in Python 2026: Zero-Copy Magic for Large Binary Data + Real Examples import() in Python 2026: How It Works, Security Risks & Modern Alternatives (importlib) zip() in Python 2026: How to Use It, strict=True Behavior & Real-World Examples vars() in Python 2026: Accessing Object Namespace + Modern Introspection Patterns type() in Python 2026: Dynamic Type Inspection & Object Creation + Modern Patterns tuple() in Python 2026: Immutable Sequences + Modern Patterns & Best Practices super() in Python 2026: Method Resolution & Modern Inheritance Patterns sum() in Python 2026: Summing Iterables + Modern Numeric Patterns & Best Practices staticmethod() in Python 2026: Static Methods, Utility Functions & Modern Best Practices sorted() in Python 2026: Sorting Iterables + Modern Patterns & Best Practices slice() in Python 2026: Creating Slice Objects + Modern Patterns & Best Practices setattr() in Python 2026: Dynamic Attribute Setting + Modern Patterns & Safety set() in Python 2026: Mutable Sets Creation + Modern Patterns & Best Practices