Python Module 16 – Practical Python: text, CLIs, web, automation and security
Template strings (t-strings) in Python 3.14
Write t-strings in Python 3.14, read their Template and Interpolation parts, and build HTML and SQL helpers that keep user input from turning into code.
What you will learn
- Create t-strings and inspect Template and Interpolation objects
- Write a safe HTML renderer that escapes interpolations
- Explain how t-strings help prevent injection bugs
- Convert a t-string into a sqlite3 query with placeholders and parameters
- Predict how templates compare, join and evaluate their values
Before you start
What you’ll learn · Before you start
What you will learn
- Create t-strings and inspect Template and Interpolation objects
- Write a safe HTML renderer that escapes interpolations
- Explain how t-strings help prevent injection bugs
- Convert a t-string into a sqlite3 query with placeholders and parameters
- Predict how templates compare, join and evaluate their values
Before you start
On this page
A comment box receives <script>alert("hi")</script>, and a page that puts the comment into its HTML with an
f-string sends that script to every visitor’s browser. No helper could have stopped it: by the time any code saw the
text, the comment and the markup were one finished string.
A t-string, t"Hello {name}", keeps them apart. It evaluates to a string.templatelib.Template, an object that
holds the text you wrote and each interpolated value separately, so a function can decide how to combine them:
escape every value for HTML, or turn every value into an SQL parameter. Template strings arrived in Python 3.14
through PEP 750. Use them when a helper builds HTML, SQL, shell commands or log records from values; for text that
only people read, f-strings stay the simpler choice.
A t-string evaluates to a Template, not to text
from string.templatelib import Interpolation
name = "Asha"
amount = 1234.5
greeting = t"Hi {name!r}, you owe {amount:,.2f} by Friday."
print(type(greeting))
print(greeting.strings)
print(greeting.values)
# Iterating gives the text you wrote and an Interpolation for every {...}, in order.
for part in greeting:
if isinstance(part, Interpolation):
print(f" value={part.value!r} expression={part.expression!r} "
f"conversion={part.conversion!r} format_spec={part.format_spec!r}")
else:
print(f" text {part!r}")
# A Template has no text of its own: str() shows its repr.
print(str(greeting)) Output
<class 'string.templatelib.Template'>
('Hi ', ', you owe ', ' by Friday.')
('Asha', 1234.5)
text 'Hi '
value='Asha' expression='name' conversion='r' format_spec=''
text ', you owe '
value=1234.5 expression='amount' conversion=None format_spec=',.2f'
text ' by Friday.'
Template(strings=('Hi ', ', you owe ', ' by Friday.'), interpolations=(Interpolation('Asha', 'name', 'r', ''), Interpolation(1234.5, 'amount', None, ',.2f')))
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parts.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The template keeps its pieces in three tuples: strings holds the text you wrote, always one item more than there
are interpolations, interpolations holds an Interpolation for every pair of braces, and values holds the
evaluated values. Step through the template that the program built:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The text you wrote. The t-string runs when its line runs, like an f-string, and gives a Template. It cuts the written text at every pair of braces and keeps the pieces in strings, three for two pairs of braces.
- The first value. The first pair of braces becomes an Interpolation: value 'Asha', expression 'name', the source text between the braces, and conversion 'r' from the !r. Its format_spec is empty.
- The second value. The second holds value 1234.5, expression 'amount' and format_spec ',.2f'. The spec was recorded, not applied, so the value is still the float 1234.5.
- The order of a loop. A for loop over the template meets the parts in their order on the page, text and interpolations taking turns, and skips any piece of text that is empty.
- No text of its own. values is the tuple ('Asha', 1234.5), and str() of the template shows its repr, because a Template turns into text only when a function decides how.
An interpolation carries four things: value, the result of the expression; expression, the source text between
the braces; conversion, "r", "s", "a" or None; and format_spec, the text after the colon. Iterating the
template gives the text and the interpolations, in order, and skips empty text.
Two details in the output matter. The conversion: !r, !s or !a after the expression in an f-string's braces: the value is turned into text with repr(), str() or ascii() before it is formatted. f"{city!r}" shows a string in quotes. More in “f-strings and the format mini-language” !r and the
format specification: The code after the colon in a replacement field, such as >12,.2f, that sets fill and alignment, sign, width, grouping, precision and type. format(value, spec) applies one on its own. More in “f-strings and the format mini-language” ,.2f were recorded but not applied: the value is still the float 1234.5,
and applying them, or ignoring them, is the processing function’s choice. And str() of the template printed its
repr, not a greeting, because a Template has no text of its own: code that forgets to process a template shows
Template(strings=… on screen instead of hiding the bug.
The prefix can be combined with r for a raw t-string, rt"\d{4}-{year}", where backslashes stay as written, but
not with b: there are no bytes templates. Any expression an f-string: A string literal with f before its opening quote, such as f"{price:.2f}". The expressions in its braces are evaluated when the line runs, and their values are put into the text. More in “f-strings and the format mini-language” accepts works
between the braces, and the expression attribute keeps its source text, which processors use as a name, for example
for a log field.
A processing function escapes the values and keeps the text
import html
from string.templatelib import Interpolation, Template
def render_html(template: Template) -> str:
"""Join a template, escaping every interpolated value for HTML."""
out = []
for part in template:
if isinstance(part, Interpolation):
out.append(html.escape(str(part.value))) # a value: escape it
else:
out.append(part) # text written in the code: keep it
return "".join(out)
user = "Ravi <admin>"
comment = '<script>alert("hi")</script> & thanks'
print(render_html(t"<p><b>{user}</b>: {comment}</p>"))
# An f-string has already joined everything, so nothing can tell the values apart any more.
print(f"<p><b>{user}</b>: {comment}</p>") Output
<p><b>Ravi <admin></b>: <script>alert("hi")</script> & thanks</p>
<p><b>Ravi <admin></b>: <script>alert("hi")</script> & thanks</p>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 html_render.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
render_html() is a processing function, the piece that gives a template its meaning. It keeps the text written in
the code, the <p> and <b> tags, and passes every value through html.escape(), so the user name and the comment
arrive as visible characters instead of markup. The same loop, written at the top level so that every pass can be
stepped through:
Program · html_trace.py
from html import escape
from string.templatelib import Interpolation
user = "Ravi <admin>"
page = t"<p><b>{user}</b></p>"
out = []
for part in page:
if isinstance(part, Interpolation):
out.append(escape(part.value))
else:
out.append(part)
print("".join(out))
print(f"<p><b>{user}</b></p>") Variables
- changed in this step
Printed so far
Nothing printed yet<p><b>Ravi <admin></b></p> <p><b>Ravi <admin></b></p>
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 18 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
from html import escapeloadsescape. - Line 2.
from string.templatelib import InterpolationloadsInterpolation. - Line 4.
user = "Ravi <admin>"setsuserto'Ravi <admin>'. - Line 5.
page = t"<p><b>{user}</b></p>"setspagetoTemplate(strings=('<p><b>', '</b></p>'), interpolations=(In…. - Line 6.
out = []makesouta list of 0 items. - Line 7.
for part in page:starts:partis'<p><b>'. A loop over a template gives its parts in order. The first is text written in the code, '<p><b>'. - Line 8.
isinstance(part, Interpolation)is false (partis'<p><b>'), so the else block runs. - Line 11.
out.append(part)adds'<p><b>'to the end ofout. Key point Text written in the code is markup the programmer meant, so the else branch keeps it as it is. - Line 7.
for part in page:goes round again:partisInterpolation('Ravi <admin>', 'user', None, ''). - Line 8.
isinstance(part, Interpolation)is true (partisInterpolation('Ravi <admin>', 'user', None, '')), so the block underif isinstance(part, Interpolation):runs. - Line 9.
out.append(escape(part.value))adds'Ravi <admin>'to the end ofout. The fix The value came from a user, so it is escaped:<and>become<and>, and the browser shows them as characters instead of reading a tag. - Line 7.
for part in page:goes round again:partis'</b></p>'. - Line 8.
isinstance(part, Interpolation)is false (partis'</b></p>'), so the else block runs. - Line 11.
out.append(part)adds'</b></p>'to the end ofout. - Line 7.
for part in page:has no item left, so the loop ends. - Line 12.
print("".join(out))prints<p><b>Ravi <admin></b></p>. The page has its tags, and the user name appears as text. - Line 13.
print(f"<p><b>{user}</b></p>")prints<p><b>Ravi <admin></b></p>. That was the last line, so the program ends. The bug The f-string pasted the value into the text before anything could look at it, so its <admin> reaches the browser as a tag.
The f-string on the last line had no such chance: by the time any function received it, the <script> from the
comment was part of one finished string, indistinguishable from the tags the programmer wrote. That separation is the
whole idea, and PEP 750 names injection attacks, such as
cross-site scripting: A weakness in which text from one user reaches a page without escaping, so the browser reads it as markup and can run code in other visitors' browsers. Escaping output for its place in the page prevents it. More in “XML and HTML parsing without security holes”, among its reasons. Escaping still has to suit the place
where the value lands: html.escape() protects text between tags and quoted attribute values, but a link in href
also needs its scheme checked, and code inside <script> needs a different encoder altogether.
Key idea
A t-string hands its processing function the written text and the values apart, so the function can trust the first and escape the second; an f-string has already joined them.
Checkpoint
Check: The parts of a Template · Escaping in a processing function
f-strings, t-strings and string.Template differ in when values join the text
All three put values into text. Read the table row by row for when the values join the written text, and who decides how:
| Criterion | f-string | t-string | string.Template |
|---|---|---|---|
| Written as | f"Hi {name}" in code | t"Hi {name}" in code | Template("Hi $name"), often from a file |
| Evaluates to | A finished str | A Template object | A Template, filled later |
| Values are | Formatted and inserted at once | Computed at once, combined by a function | Looked up by name when filled |
| Escaping | None, the text is final | Whatever the function does | None |
| When to choose | Messages people read | HTML, SQL, logs and shell built by helpers | Text that people edit |
Ask who combines the values
If nobody needs to treat the values differently, an f-string is enough. If a helper must escape or bind them, write
a t-string. If someone else writes the text, fill a string.Template.
A processor can also format, recurse, bind and log
From a t-string to HTML, SQL or a log record
Text description of the diagram
The diagram has three steps, one under the other.
- The t-string t"<b>{user}</b>" is written in the code.
- It is evaluated at once into a Template, which keeps the written text, "<b>" and "</b>", apart from the interpolated value, user.
- A processing function decides how to combine them. html() escapes each value, so the user name Ravi <admin> appears as Ravi <admin> between the bold tags. sql() puts a question mark in place of each value and passes the values as parameters. log_record() builds a message for people and keeps the values as separate fields for search.
A real processor usually does more than escape. string.templatelib.convert() applies an interpolation’s !r, !s
or !a conversion the way an f-string would. format(value, spec) applies its format spec, such as ,.2f. A value
that is itself a template, such as a list item built with t"<li>{item}</li>", can be rendered recursively. An SQL
processor writes a ? placeholder (SQL): A marker such as ? or :name where a value goes in an SQL statement. The driver sends the values apart from the SQL text, and the database binds them to the compiled statement as data. More in “SQLite databases with sqlite3” for every value and returns the values as
parameters. A logging processor returns a readable message together with the values as separate fields. Templates
can also be joined with +, but only to other templates.
Version note
t-strings need Python 3.14 or later. On Python 3.13 a file that contains one is a SyntaxError before any of it
runs, so libraries that support older versions cannot use them yet. The standard library’s sqlite3 does not accept
templates either, and raises TypeError for one: keep passing its ? placeholders and parameters, or convert a template into
that pair yourself.
Checkpoint
Check: What else a processor does
Check yourself
Eight questions on the three ideas above follow, one at a time, in the order your answers call for.
Practice that adapts to you
Practise this lesson
8 questions about 9 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Processors for SQL, logs and nested HTML, with an injection attempt run Pro: opens with a Pro, Premium or Ultimate pass
A processing function is where a template’s safety is won or lost. Here an injection attempt runs against an f-string query and a t-string query, a quoting mistake is caught, one template becomes a log message and its fields, and one renderer handles conversions, format specs and nested templates.
In this part:
- An injection attempt, run against an f-string and a t-string
- One template, two results: a message and searchable fields
- Conversions, format specs and nested templates in one renderer
- Exercise: an HTML renderer with a safe marker, and SQL with placeholders
- Render t-strings as escaped HTML and as SQL with placeholders
1 diagram · 3 runnable examples (Python) · 1 exercise with sample tests · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
How templates compare, join and evaluate, with their cost measured Pro: opens with a Pro, Premium or Ultimate pass
Templates behave differently from strings in seven ways that surprise people, and processing them costs time. Each behaviour is run below, the cost is measured against an f-string, and the adoption advice that follows rests on both.
In this part:
- Seven behaviours that surprise people, each run
- What a t-string costs, measured against an f-string
- Caching work by the template’s strings
- When to use t-strings now, and what to keep
2 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What does a t-string evaluate to in Python 3.14, and how is it different from an f-string?
An f-string evaluates to a finished str: the values are formatted and inserted at once, so nothing afterwards can tell the text you wrote from the values. A t-string, written t"Hi {name}", evaluates to a string.templatelib.Template instead. The template keeps the written text in its strings attribute and each interpolated value as an Interpolation with value, expression, conversion and format_spec, and conversions and format specs are recorded rather than applied. A function then decides how to combine the parts: it can escape every value for HTML, turn values into SQL parameters, or keep them as fields of a log record. The values are still computed when the t-string is created. A Template has no text of its own, so str() shows its repr. t-strings need Python 3.14 or later.
-
Mid to senior
A helper run_query(sql: str) is called as run_query(f"SELECT * FROM users WHERE email = '{email}'") all over a codebase that now runs on Python 3.14. Explain the risk, and design a replacement based on t-strings, including quoted placeholders, lists of values and table names.
-
Mid to senior
Design a small HTML library for a dashboard in which all markup is built with t-strings. Which parts does it escape and which does it trust, how do nested components and attributes work, and how do you stop developers from bypassing the escaping by accident?
-
Senior to staff
Your team maintains a library that supports Python 3.10 to 3.14, and someone asks to switch to t-strings everywhere. What do you advise, what would you measure, and how would you introduce t-strings without breaking users on older versions?
Key takeaways
t"..."evaluates to astring.templatelib.Template, not astr: itsstringshold the written text, and its interpolations hold eachvalue,expression,conversionandformat_spec.- Conversions and format specs are recorded, not applied, and
str()of a template shows its repr. - A processing function gives a template its meaning: it escapes the values for HTML, binds them for SQL or keeps them as fields for logs, while the written text stays as it is.
- t-strings need Python 3.14, and sqlite3 still takes its
?placeholders and parameters.
References
- string.templatelib: Support for template string literals (Python Software Foundation)
- What's New In Python 3.14: PEP 750, template string literals (Python Software Foundation)
- PEP 750: Template Strings (Python Software Foundation)
- Lexical analysis: Template string literals (Python Software Foundation)
- html.escape() (Python Software Foundation)
- sqlite3: How to use placeholders to bind values in SQL queries (Python Software Foundation)
- SQL Injection Prevention Cheat Sheet (OWASP Foundation)
- Cross Site Scripting Prevention Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress