T-strings: Python's Fifth String Formatting Technique?

Share
Copied to clipboard.
Trey Hunner smiling in a t-shirt against a yellow wall
Trey Hunner
6 min. read 4 min. video Python 3.14

Let's talk about Python's t-strings.

String formatting: a very brief history

Python has had many different waves of string interpolation over the years.

It's had percent-style (%) string formatting since pretty much the beginning:

>>> name = "Trey"
>>> n = 3
>>> "%s, you have %d new messages." % (name, n)
'Trey, you have 3 new messages.'

Then Python 2.4 added a Template class to the string module:

>>> from string import Template
>>> t = Template("$name, you have $n new messages.")
>>> t.substitute(name=name, n=n)
'Trey, you have 3 new messages.'

And Python 2.6 added an even easier version of string formatting with the string format method:

>>> "{name}, you have {n} new messages.".format(name=name, n=n)
'Trey, you have 3 new messages.'

Then Python 3.6 added a new string formatting syntax with f-strings:

>>> f"{name}, you have {n} new messages."
'Trey, you have 3 new messages.'

Now, Python 3.14 has yet another string interpolation syntax with t-strings:

>>> t"{name}, you have {n} new messages."
Template(strings=('', ', you have ', ' new messages.'), interpolations=(Interpolation('Trey'
, 'name', None, ''), Interpolation(3, 'n', None, '')))

Well, sort of!

How are t-strings different

Unlike f-strings, t-strings don't actually make strings. But they are useful.

T-strings are for lazy string interpolation.

Python's f-strings immediately interpolate the expressions within their replacement fields (the bit between the curly braces). Usually this eager interpolation is fine, but sometimes it can cause headaches.

The problem with f-strings

Let's say we're reinventing the dedent function from Python's textwrap module:

>>> print(dedent("""
...     Look at this indented text!
...     Wait... is it *not* indented anymore?
... """.strip("\n")))
Look at this indented text!
Wait... is it *not* indented anymore?

Just like the original dedent function, our version removes all common indentation, while maintaining the relative indentation of each line:

>>> print(dedent("""\
...     one
...       two
...         three
... """))
one
  two
    three

It works... But if we use f-strings with dedent, we might have a problem.

Here is a string that has multiple lines within it:

>>> code = r"""
... def strip_each(lines):
...     new_lines = []
...     for line in lines:
...         new_lines.append(line.rstrip("\n"))
...     return new_lines
... strip_each(["one\n", "two\n"])
... """.strip("\n")

And we try to use it in an f-string and we then pass that f-string to dedent:

>>> from textwrap import dedent
>>> print(dedent(f"""\
...     Example function and function call:
...
...         {code}
...
...     Was this indented properly?"""))
...

We won't end up with the unindent text that we were expecting:

>>> print(dedent(f"""\
...     Example function and function call:
...
...         {code}
...
...     Was this indented properly?"""))
    Example function and function call:
        def strip_each(lines):
    new_lines = []
    for line in lines:
        new_lines.append(line.rstrip("\n"))
    return new_lines
strip_each(["one\n", "two\n"])

    Was this indented properly?

The output string looks all weird.

The first line of our replacement field string is indented, and the other lines are only indented as much as they were in the original string. Also, the entire output string wasn't actually dedented at all.

Our f-string put a string that lacked common indentation inside of a larger string that had common indentation:

>>> print(dedent(f"""\
...     Example function and function call:
...
...         {code}
...
...     Was this indented properly?"""))

By the time the resulting string was passed to dedent, it didn't look like something that could actually be dedented any further.

So currently, our dedent function has no way to know what the original f-string looked like before it was interpolated, and then retroactively indent the replacement fields before dedenting the full string.

This is where t-strings come in handy.

T-strings return templates

F-strings return strings:

>>> name = "Trey"
>>> f"Hello {name}"
'Hello Trey'

T-strings return string.templatelib.Template objects:

>>> t"Hello {name}"
Template(strings=('Hello ', ''), interpolations=(Interpolation('Trey', 'name', None, ''),))

If we loop over these Template objects, we'll get a mix of strings interleaved with interpolation objects:

>>> list(t"Hello {name}")
['Hello ', Interpolation('Trey', 'name', None, '')]

This allows Python developers to make tools that can accept t-strings and can pre-process the interpolation parts before combining all the parts together.

Lazy string interpolation with t-strings

So it's possible to make a dedent function which accepts a t-string:

>>> code = r"""
... def strip_each(lines):
...     new_lines = []
...     for line in lines:
...         new_lines.append(line.rstrip("\n"))
...     return new_lines
... strip_each(["one\n", "two\n"])
... """.strip("\n")
>>> print(dedent(t"""\
...     Example function and function call:
...         {code}
...
...     Was this indented properly?"""))

This version of dedent properly dedents the way we're probably expecting it to:

>>> print(dedent(t"""\
...     Example function and function call:
...         {code}
...
...     Was this indented properly?"""))
Example function and function call:
    def strip_each(lines):
        new_lines = []
        for line in lines:
            new_lines.append(line.rstrip("\n"))
        return new_lines
    strip_each(["one\n", "two\n"])

Was this indented properly?

This version of dedent was able to see that the replacement field was relatively indented within the larger string, but the actual contents of that field weren't actually indented, so they needed to be properly indented before we could dedent the rest of the string.

A dedent function that uses t-strings

This custom dedent function was specifically designed to work with t-strings:

from string import templatelib
import re
import textwrap

_INDENT_BEFORE = re.compile(r"(?m)^([ \t]+).*?(?<!{)(?:{{)*{(\d+)}")

def dedent(template: templatelib.Template) -> str:
    replacements = []
    parts = []
    n = 0
    for item in template:
        match item:
            case templatelib.Interpolation(value, _, conversion, format_spec):
                value = templatelib.convert(value, conversion)
                replacements.append(format(value, format_spec))
                parts.append("{" + str(n) + "}")
                n += 1
            case _:
                parts.append(item.replace("{", "{{").replace("}", "}}"))
    text = textwrap.dedent("".join(parts))
    for indent, n in _INDENT_BEFORE.findall(text):
        n = int(n)
        replacements[n] = textwrap.indent(replacements[n], indent).removeprefix(indent)
    return text.format(*replacements)

It does wrap around the version of dedent from Python's textwrap module. But it also needs to carefully process the interpolated value appropriately, depending on its contents and where it appears within the bigger string.

You can actually find this version of dedent as a library on the Python Package Index. It's called better-dedent:

>>> pip install better-dedent

It probably has bugs, so feel free to report them to me if you find any.

When to use t-strings

T-strings are primarily useful for the maintainers of libraries that are used by other Python developers.

Anytime you're designing a tool where you need to pre-process smaller input strings before combining them to a larger string, like escaping SQL, HTML, or regular expressions, you might want to use a t-string. Or, if you need to delay string interpolation for some other reason, like Python's logging module does, t-strings could come in handy in that case as well.

If you don't maintain a library where your users would benefit from delayed string interpolation or automatic pre-processing of inputs, then you don't need to think about t-strings until a library tells you to use one.

T-strings are primarily for library authors

Writing a t-string is pretty much as simple as writing an f-string. You just use a t instead of an f.

But using the Template object that you get back from a t-string isn't nearly as simple as using a string.

You probably won't use t-strings and Template objects in your own code, but you might pass them to code written by others. If a library is specifically designed to accept t-strings, you'll pass it a t-string.

Python 3.14 was just released so there aren't many uses for t-strings in the wild yet, but there is an awesome t-string list that the creators of t-strings are compiling for those of us who find them kind of neat.

A Python Tip Every Week

Need to fill-in gaps in your Python skills? I send weekly emails designed to do just that.