F-Strings Are F'ing Cool

Posted on Thu 12 July 2018 in Posts

In Python 3.6 a new (yet another) way to format strings was introduced -- F-strings. These were introduced as part of PEP-498, and while there can be a "really do we need yet another way to do this?" response to them when you first see them, in actuality they are one of the coolest additions to the language I've seen in recent years.

The Basics

You denote an f-string by prefixing a f or F in front of a string literal:

mystring = f'This is an f-string!'
print(mystring)  # prints "This is an f-string!"

Nothing exciting here, where f-strings get more interesting is when you want to interpolate a value into it:

x = 42
mystring = f'The answer to life, the universe and everything is {x}'
print(mystring)  # prints "The answer to life, the universe and everything is 42"

Ok, so that's just like the string formatting operator (%) and the format() function. Ie these are all equivalent:

x = 42
with_string_op = 'The answer to life, the universe and everything is %d' % x
with_format_fn = 'The answer to life, the universe and everything is {}'.format(x)
with_f_string = f'The answer to life, the universe and everything is {x}'

print(with_string_op == with_format_fn == with_f_string)  # prints "True"

Ok, I hear you saying that's neat, but isn't that just syntactic sugar? Adam, why do you think F-strings are cool? Let's see some more cool tricks and reasons why you should start using them.

Arbitrary Expressions

Note that the stuff inside the braces can be arbitrary Python expressions:

x = 100
y = 99
print(f'{x} + {y} * 2 is equal to {x + y * 2}')  # prints "100 + 99 * 2 is equal to 298"

This affords an enormous amount of flexibility, as pretty much anything that's a valid Python bit of code could be put into an f-string block. This also tends to make for strings that are more readable, consider:

logging.warn("Disk space for drive {} is low, only {} bytes remaining".format(driveid, space_left))

vs

logging.warn(f"Disk space for drive {driveid} is low, only {space_left} bytes remaining")

Because the identifiers are right in the middle of the string literal, it's easier to envision in your head what the final string will be, rather than doing the "ok, this first argument goes here, and the second goes there" mental mapping you do with the format() call. It is worth noting you can get the same thing with format, but it becomes very verbose:

logging.warn(
    "Disk space for drive {driveid} is low, only {space_left} bytes remaining".format(
        driveid=driveid, space_left=space_left
    )
)

I find that hard to read, and I very much don't like the alignment of the arguments across multiple lines. I formatted that line with Black, so perhaps it's a matter of my taste not aligning with that formatter, but I've always struggled with manually formatting complex format() calls as it always feels like the final result is ugly.

Fast

This is cool, f-strings are also fast. Why? Because the expression is treated like a plain old python expression, parsed & evaluated at runtime. They also save the overhead of a function call (like with format()), which we can see with the dis module:

>>> import dis
>>> def foo():
...     x = 42
...     y = 99
...     return '{} + {} = {}'.format(x, y, x + y)
...
>>> foo()
'42 + 99 = 141'
>>> dis.dis(foo)
  2           0 LOAD_CONST               1 (42)
              2 STORE_FAST               0 (x)

  3           4 LOAD_CONST               2 (99)
              6 STORE_FAST               1 (y)

  4           8 LOAD_CONST               3 ('{} + {} = {}')
             10 LOAD_ATTR                0 (format)
             12 LOAD_FAST                0 (x)
             14 LOAD_FAST                1 (y)
             16 LOAD_FAST                0 (x)
             18 LOAD_FAST                1 (y)
             20 BINARY_ADD
             22 CALL_FUNCTION            3
             24 RETURN_VALUE

The output of dis can be a bit hard to read, but notice the LOAD_ATTR line, which is Python doing a lookup of the format function, and then there's the CALL_FUNCTION instruction which is actually calling that function so you pay the overhead of a function call (which in Python has always been surprisingly expensive).

With f-strings we don't have that:

>>> def foo():
...     x = 42
...     y = 99
...     return f'{x} + {y} = {x + y}'
...
>>> dis.dis(foo)
  2           0 LOAD_CONST               1 (42)
              2 STORE_FAST               0 (x)

  3           4 LOAD_CONST               2 (99)
              6 STORE_FAST               1 (y)

  4           8 LOAD_FAST                0 (x)
             10 FORMAT_VALUE             0
             12 LOAD_CONST               3 (' + ')
             14 LOAD_FAST                1 (y)
             16 FORMAT_VALUE             0
             18 LOAD_CONST               4 (' = ')
             20 LOAD_FAST                0 (x)
             22 LOAD_FAST                1 (y)
             24 BINARY_ADD
             26 FORMAT_VALUE             0
             28 BUILD_STRING             5
             30 RETURN_VALUE

Note no function lookup. To give an idea of timing, let's use the timeit module, first with the format() function:

>>> timeit.timeit("foo()", setup="from __main__ import foo")
0.807306278962642

And now with f-strings:

>>> timeit.timeit("foo()", setup="from __main__ import foo")
0.6124071741942316

Ballpark 25% faster, not bad. And just for completeness sake, what about the string formatting operator:

>>> def foo():
...      x = 42
...      y = 99
...      return '%d + %d = %d' % (x, y, x + y)
...
>>> foo()
'42 + 99 = 141'
>>> timeit.timeit("foo()", setup="from __main__ import foo")
0.5939487249124795

Oooh, that's interesting, just a smidge faster than f-strings. Still, unlike the format() function, there's no performance overhead to f-strings, and in some cases it can be the fastest of all three approaches.

Using format specifiers

This is where you can do some neat tricks. If you've never really played with format specifiers in Python, they can be quite useful. The relevant docs: https://docs.python.org/3.7/library/string.html#format-specification-mini-language

The Format Specification mini language is used with the format() function, but as it turns out, you can also use format specifiers with f-strings. For example:

print(f"Pi to 3 digits: {math.pi:.3f}")  # prints "Pi to 3 digits: 3.142"

This can be handy for formatting as well:

print(f"{42:05}")  # gives "00042"

print(f"{42:a<10}")  # gives "42aaaaaaaa"

print(f"{42:a>10}")  # gives "aaaaaaaa42"

print(f"{42:3>10}")  # gives "3333333342"

print(f"{42:=^10}")  # gives "====42===="

Type specific formatting is also supported, for example avoiding calling strftime() to format a datetime:

>>> from datetime import datetime
>>> f"Today is {datetime.now():%B %d, %Y}"
'Today is July 15, 2018'

There's some really cool examples of format specifiers in the docs , I'd definitely suggest giving it a read.

The best of all worlds

Ultimately, F-strings are really flexible, and to me represent the best of all worlds. You get the performance of the string formatting operator, the flexibility of the format() function, and syntactically it all tends to read better as well.