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 <admin></h3>
# <p><script>alert("hacked")</script></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!