Strings and f-strings: Immutability, Formatting, and the Quadratic Loop

7 minute read

Published:

TL;DR: A str is an immutable sequence of Unicode code points. Every "modifying" method returns a new string, so s.replace(...) without assigning it does nothing. f-strings (3.6+) are the formatting tool to use: f"{value!r:>10.3f}" packs conversion, alignment and precision into one expression, and f"{x=}" prints the expression alongside its value. Build strings by collecting pieces in a list and calling "".join(parts) — repeated += is quadratic whenever CPython's in-place optimisation does not apply.

An immutable sequence

Strings index, slice and iterate exactly like lists — but nothing can be assigned:

s = "hello"
print(s[0], s[-1], s[1:4], len(s))   # -> h o ell 5

s[0] = "H"
# TypeError: 'str' object does not support item assignment

So s.upper() returns a new string and leaves s alone. Forgetting to bind the result — writing s.strip() on a line by itself and wondering why the whitespace survived — is the single most common string bug.

Iterating a string yields one-character strings; there is no separate character type. Concatenation is +, repetition is *, and membership is a substring test: "ell" in "hello" is True.

The methods worth knowing

MethodEffectReturns
s.split(sep=None, maxsplit=-1)split on sep; with no argument, split on runs of whitespace and drop emptieslist[str]
s.splitlines()split on line boundarieslist[str]
sep.join(iterable)concatenate an iterable of strings with sep betweenstr
s.strip(chars=None)remove leading and trailing whitespace, or any of charsstr
s.lstrip / s.rstripone side onlystr
s.replace(old, new, count=-1)replace occurrences, optionally at most countstr
s.find(sub)index of first occurrence, or -1int
s.index(sub)same, but raises ValueError if absentint
s.startswith(p) / s.endswith(p)prefix / suffix test; p may be a tuplebool
s.count(sub)non-overlapping occurrencesint
s.upper / .lower / .title / .capitalizecase conversionstr
s.zfill(w) / .ljust(w,c) / .rjust / .centerpad to width wstr
s.isdigit / .isalpha / .isspacecharacter-class testsbool

All are \(O(n)\) or better, and none mutate — there is nothing to mutate.

line = "  name, age , city  "
print(line.split(","))                            # -> ['  name', ' age ', ' city  ']
print([p.strip() for p in line.strip().split(",")])   # -> ['name', 'age', 'city']
print("a-b-c".split("-", 1))                      # -> ['a', 'b-c']
print("-".join(["a", "b", "c"]))                  # -> a-b-c

Two details that catch people. split() with no argument is not the same as split(" "): the former collapses runs of whitespace and drops empty fields, the latter does neither. And strip("xy") removes any leading or trailing character in the set {x, y} — it is not a prefix removal. For that, Python 3.9 added removeprefix and removesuffix.

t = "hello world"
print(t.replace("o", "0"))       # -> hell0 w0rld
print(t.replace("o", "0", 1))    # -> hell0 world
print(t.find("world"), t.find("zzz"))   # -> 6 -1
print(t.endswith(("x", "ld")))          # -> True

f-strings

An f-string evaluates the expressions inside its braces at runtime and formats each with the mini-language after the colon. It is faster than str.format (the interpolation is compiled in, not parsed at call time) and far easier to read.

name, n, pi = "ada", 7, 3.14159
print(f"{name} has {n} items")   # -> ada has 7 items

The = suffix (Python 3.8+) prints the source text of the expression together with its value — a debugging shortcut worth using constantly:

print(f"{n=}, {pi=}")     # -> n=7, pi=3.14159

The format spec is [[fill]align][sign][#][0][width][,][.precision][type]:

print(f"{pi:.2f}")           # -> 3.14
print(f"|{pi:10.3f}|")       # -> |     3.142|   width 10, numbers right-align
print(f"|{pi:<10.3f}|")      # -> |3.142     |
print(f"{n:03d}")            # -> 007
print(f"{n:+d}")             # -> +7
print(f"{1234567:,}")        # -> 1,234,567
print(f"{0.256:.1%}")        # -> 25.6%
print(f"{1234.5678:e}")      # -> 1.234568e+03

Alignment uses < left, > right, ^ centre, with an optional fill character in front:

print(f"|{name:>10}|")   # -> |       ada|
print(f"|{name:<10}|")   # -> |ada       |
print(f"|{name:^10}|")   # -> |   ada    |
print(f"|{name:*^11}|")  # -> |****ada****|

Integers can be rendered in other bases with b, o, x, X, and # adds the prefix:

print(f"{255:b} {255:o} {255:x} {255:#x}")    # -> 11111111 377 ff 0xff

Two more pieces. !r applies repr() instead of str(), which is what you want in log messages because it shows the quotes:

print(f"{'quoted'!r}")    # -> 'quoted'

And the spec itself can be computed, by nesting braces:

w = 8
print(f"|{pi:{w}.3f}|")   # -> |   3.142|

The older mechanisms still appear in code you will read: "{} and {}".format(a, b) with positional or named fields, and the C-style "%s is %d" % (a, b). Both work; neither is a reason to write new code that way.

Raw strings

A raw string literal, prefixed r, turns off backslash escape processing:

print(repr("C:\\path\\n"))   # -> 'C:\\path\\n'
print(repr(r"C:\path\n"))    # -> 'C:\\path\\n'

Both lines produce the same string — r changes how the literal is read, not what type you get. It matters most for regular expressions, where the pattern language has its own backslashes:

import re
print(re.findall(r"\d+", "a1b22"))   # -> ['1', '22']

Without the r, you would be writing "\\d+" and hoping you counted correctly. One restriction: a raw string cannot end in an odd number of backslashes.

str is not bytes

A str is a sequence of Unicode code points — abstract characters. A bytes object is a sequence of integers in \([0, 255]\). Converting between them requires naming an encoding, and there is no default that is safe to assume:

b = "café".encode("utf-8")
print(b, len(b), len("café"))   # -> b'caf\xc3\xa9' 5 4
print(b.decode("utf-8"))        # -> café

Four characters, five bytes: é needs two in UTF-8. So len on encoded data does not count characters, and slicing bytes can split a character in half.

The two types never mix implicitly:

b + "x"
# TypeError: can't concat str to bytes

"café".encode("ascii")
# UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 3

Indexing a bytes gives an int, not a one-byte bytesb[0] is 99, the code for c. The practical rule is to decode at the boundary of your program, work in str throughout, and encode again on the way out. Always pass encoding="utf-8" explicitly to open(), because the default is platform-dependent.

Why += in a loop is the wrong tool

Since strings are immutable, s += piece must in principle allocate a new string of length \(\lvert s\rvert + \lvert p\rvert\) and copy everything. Do that \(n\) times and you copy \(1 + 2 + \dots + n = O(n^2)\) characters.

CPython partly hides this: when the target is a simple local name and the string has exactly one reference, it resizes the buffer in place instead. The optimisation is real but fragile — store the string anywhere else, and the quadratic cost comes straight back. Measured on CPython 3.13, appending "abc" \(n\) times while a second name also refers to the result:

\(n\)+= with an alias"".join(parts)
25,00011.2 ms0.060 ms
50,00065.1 ms0.126 ms
100,000226.8 ms0.243 ms
200,000834.0 ms0.503 ms

Doubling \(n\) roughly quadruples the left column and doubles the right — quadratic against linear, and a factor of about 1,660 at the bottom row. join wins because it walks the iterable once to total the lengths, allocates exactly one buffer, and copies each piece exactly once.

# don't
s = ""
for p in parts:
    s += p

# do
s = "".join(parts)
Key Insight — never rely on the optimisation you cannot see: the in-place resize is a CPython implementation detail with no guarantee behind it. It is defeated by anything that takes a second reference to the string, by appending to a list element or attribute instead of a local, and by other interpreters. Writing "".join(parts) costs nothing extra to type and is linear on every implementation, which is why the standard library and every style guide prefer it.
The classic trap — string methods return, they do not modify. s.strip(), s.replace(a, b) and s.upper() all leave s untouched; you must write s = s.strip(). This is the mirror image of the list trap, where xs.sort() modifies in place and returns None. Learn the pair together: mutable containers mutate and return None; immutable ones return a new object and change nothing.

Next, the functions chapter, where mutability produces its most notorious surprise of all.

Recap

  • str is an immutable sequence of Unicode code points; every method returns a new string, so bind the result.
  • split() with no argument collapses whitespace runs; strip(chars) removes a character set, not a prefix — use removeprefix for that.
  • f-strings: {expr!r:fill align width,.precision type}, with {x=} for debugging and nested braces for a computed width.
  • Raw strings change how a literal is parsed, not its type; use them for regular expressions.
  • str and bytes never mix implicitly — decode on input, encode on output, and pass encoding="utf-8" explicitly.
  • Accumulate pieces in a list and "".join them; repeated += is quadratic whenever CPython's in-place resize does not apply.

References

  1. Python Software Foundation. Text Sequence Type — str.
  2. Python Software Foundation. Format Specification Mini-Language.
  3. Python Software Foundation. Unicode HOWTO.
  4. Smith, E. V. PEP 498 — Literal String Interpolation.
  5. Talin. PEP 3101 — Advanced String Formatting.
  6. Python Software Foundation. What’s New in Python 3.8 — f-string = specifier.
  7. Python Software Foundation. re — Regular expression operations.