Stop SQL and Shell Injection with Python 3.14 T-Strings

Your F-String Has a Security Hole

You’re building a web API. A user submits their name, and you stick it into a SQL query:

username = request.form["username"]
query = f"SELECT * FROM users WHERE name = '{username}'"
cursor.execute(query)

You already know the problem. If username is '; DROP TABLE users; --, your database just had a very bad day. F-strings are convenient, but they flatten everything into a string immediately — there’s no checkpoint between “here’s the template” and “here’s the final string.” You can’t inspect, sanitize, or transform the interpolated values before they become part of the output.

Python 3.14 introduces t-strings (PEP 750), a new string prefix that gives you exactly that checkpoint. By the end of this article, you’ll know how to use t-strings to build injection-proof SQL queries, safe shell commands, and auto-escaped HTML — all with the familiar f-string syntax you already love.

How T-Strings Work: Templates, Not Strings

A t-string looks almost identical to an f-string, but it evaluates to a Template object instead of a str. That Template holds the static parts and the interpolated values separately, so you can process them before combining.

from string.templatelib import Template, Interpolation

name = "Alice"
greeting = t"Hello, {name}!"

print(type(greeting))
# <class 'string.templatelib.Template'>

print(greeting.strings)
# ('Hello, ', '!')

print(greeting.interpolations)
# (Interpolation('Alice', 'name', None, ''),)

The key insight: greeting is not a string yet. It’s a structured object that knows:

  • The literal text segments ("Hello, " and "!")
  • The interpolated value ("Alice"), the expression that produced it ("name"), and any format spec

This separation is what makes safe processing possible. You write a function that receives a Template and decides how to render each interpolated value.

from string.templatelib import Template, Interpolation

def render(template: Template) -> str:
    parts: list[str] = []
    for item in template:
        if isinstance(item, str):
            parts.append(item)
        else:
            # item is an Interpolation — process the value
            parts.append(str(item.value).upper())
    return "".join(parts)

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

Notice we uppercased only the interpolated value while leaving the literal text untouched. That’s impossible with f-strings, where everything is already merged.

Preventing SQL Injection with T-Strings

Let’s build a real SQL sanitizer. The goal: accept a t-string that looks like a natural query, but output a parameterized query with placeholders.

from string.templatelib import Template, Interpolation

def sql(template: Template) -> tuple[str, list]:
    """Convert a t-string into a parameterized SQL query."""
    query_parts: list[str] = []
    params: list = []

    for item in template:
        if isinstance(item, str):
            query_parts.append(item)
        else:
            # Replace every interpolation with a placeholder
            query_parts.append("?")
            params.append(item.value)

    return "".join(query_parts), params

# Usage — looks like an f-string, behaves like a prepared statement
username = "'; DROP TABLE users; --"
age = 25

query, params = sql(t"SELECT * FROM users WHERE name = {username} AND age > {age}")

print(query)
# SELECT * FROM users WHERE name = ? AND age > ?

print(params)
# ["'; DROP TABLE users; --", 25]

The malicious input is safely captured as a parameter — it never touches the query string. Your database driver handles the escaping. You get the readability of f-strings with the safety of parameterized queries.

Compare this to the old way:

# ❌ F-string: injection-vulnerable
query = f"SELECT * FROM users WHERE name = '{username}' AND age > {age}"

# ❌ Manual parameterization: safe but clunky and error-prone
query = "SELECT * FROM users WHERE name = ? AND age > ?"
params = [username, age]

# ✅ T-string: readable AND safe
query, params = sql(t"SELECT * FROM users WHERE name = {username} AND age > {age}")

The t-string version reads naturally, and you can’t accidentally forget to parameterize a value — every interpolation is automatically captured.

Composing Queries Safely

T-strings also support composition. You can nest templates and handle them recursively:

from string.templatelib import Template, Interpolation

def sql(template: Template) -> tuple[str, list]:
    """Convert a t-string into a parameterized SQL query, supporting nested templates."""
    query_parts: list[str] = []
    params: list = []

    for item in template:
        if isinstance(item, str):
            query_parts.append(item)
        elif isinstance(item.value, Template):
            # Recursively process nested t-strings
            nested_query, nested_params = sql(item.value)
            query_parts.append(nested_query)
            params.extend(nested_params)
        else:
            query_parts.append("?")
            params.append(item.value)

    return "".join(query_parts), params

# Build a WHERE clause conditionally
name_filter = "Alice"
min_age = 18
where = t"name = {name_filter} AND age >= {min_age}"

query, params = sql(t"SELECT * FROM users WHERE {where} ORDER BY created_at")
print(query)
# SELECT * FROM users WHERE name = ? AND age >= ? ORDER BY created_at

print(params)
# ['Alice', 18]

The nested t-string flows through the same sanitization pipeline. No special handling needed.

Safe Shell Commands with T-Strings

PEP 787 extends the t-string concept to subprocess and shlex. The idea: use t-strings to build shell commands where user input is automatically quoted.

Even before PEP 787 is fully integrated, you can build your own safe shell wrapper:

import shlex
import subprocess
from string.templatelib import Template, Interpolation

def shell(template: Template) -> list[str]:
    """Convert a t-string to a safely-escaped argument list."""
    command_str_parts: list[str] = []

    for item in template:
        if isinstance(item, str):
            command_str_parts.append(item)
        else:
            # Shell-quote the interpolated value
            command_str_parts.append(shlex.quote(str(item.value)))

    return shlex.split("".join(command_str_parts))

# User-controlled input — could be malicious
filename = "my file; rm -rf /"
user_dir = "/home/user/docs"

args = shell(t"ls -la {user_dir}/{filename}")
print(args)
# ['ls', '-la', "/home/user/docs/'my file; rm -rf /'"]

# Safe to execute:
# subprocess.run(args)

The semicolon and slash in the filename are quoted — they’re treated as literal characters, not shell metacharacters. Compare this to the dangerous alternative:

import os

# ❌ NEVER do this — shell injection
filename = "my file; rm -rf /"
os.system(f"ls -la /home/user/docs/{filename}")
# This executes: ls -la /home/user/docs/my file; rm -rf /

Automatic HTML Escaping

Cross-site scripting (XSS) is another place where “just use an f-string” goes wrong. T-strings make auto-escaping trivial:

import html
from string.templatelib import Template, Interpolation

def html_template(template: Template) -> str:
    """Render a t-string with HTML-escaped interpolations."""
    parts: list[str] = []

    for item in template:
        if isinstance(item, str):
            parts.append(item)
        elif isinstance(item.value, Template):
            # Nested templates are recursively escaped
            parts.append(html_template(item.value))
        else:
            parts.append(html.escape(str(item.value)))

    return "".join(parts)

# User-submitted content — could contain malicious scripts
user_comment = '<script>alert("hacked")</script>'
username = "Bob <admin>"

page = html_template(t"""
<div class="comment">
    <h3>{username}</h3>
    <p>{user_comment}</p>
</div>
""")

print(page)
# <div class="comment">
#     <h3>Bob &lt;admin&gt;</h3>
#     <p>&lt;script&gt;alert(&quot;hacked&quot;)&lt;/script&gt;</p>
# </div>

Every interpolated value is escaped. The literal HTML structure passes through untouched. XSS neutralized.

Common Mistakes with T-Strings

Mistake 1: Converting to string too early

from string.templatelib import Template

name = "Alice"

# ❌ Wrong: str() on a Template just concatenates — no processing
template = t"Hello, {name}"
result = str(template)
print(result)
# Template(strings=('Hello, ', ''), interpolations=(Interpolation('Alice'),))
# This gives the repr, NOT processed output

# ✅ Right: use a processing function
def render(t: Template) -> str:
    return "".join(
        item if isinstance(item, str) else str(item.value)
        for item in t
    )

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

T-strings are not strings. They’re templates that need a rendering function. If you just call str() on them, you get the representation, not rendered output. Always pass them through a processing function.

Mistake 2: Forgetting that t-strings capture values eagerly

from string.templatelib import Template

items: list[str] = ["a", "b"]
template = t"Items: {items}"

# The value is captured at creation time
items.append("c")

for item in template:
    if not isinstance(item, str):
        print(item.value)
        # ['a', 'b', 'c'] — it captured the reference, not a copy!
# ✅ Right: if you need a snapshot, copy first
items = ["a", "b"]
snapshot = list(items)
template = t"Items: {snapshot}"

items.append("c")
for item in template:
    if not isinstance(item, str):
        print(item.value)
        # ['a', 'b'] — snapshot is independent

T-strings capture references, not copies. If you mutate a list or dict after creating the template, the template sees the mutation. This is the same behavior as f-strings (which would have formatted the mutated value), but it’s easier to miss with t-strings because there’s a gap between creation and rendering.

What This Means for Your Code

T-strings aren’t just a syntax curiosity — they’re a security primitive. The pattern is always the same:

  • Write a processing function that knows the rules of your output format (SQL, HTML, shell, etc.)
  • Use t-strings instead of f-strings wherever untrusted input touches template text
  • The processing function handles escaping, quoting, or parameterization automatically

You get readable code that’s secure by default, not secure by discipline. That’s a meaningful upgrade.

If you’re still on Python < 3.14, the tstrings-backport package on PyPI provides the same Template type so you can start adopting the pattern today. When you upgrade, swap the import and delete the dependency.

Next step: try replacing one f-string in your codebase that handles user input with a t-string and a processing function. Start with SQL or HTML — those are where injection bugs live.

No comments yet. Be the first to leave a comment!

Leave a Comment

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