Strings and f-strings: Immutability, Formatting, and the Quadratic Loop
Published:
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
| Method | Effect | Returns |
|---|---|---|
s.split(sep=None, maxsplit=-1) | split on sep; with no argument, split on runs of whitespace and drop empties | list[str] |
s.splitlines() | split on line boundaries | list[str] |
sep.join(iterable) | concatenate an iterable of strings with sep between | str |
s.strip(chars=None) | remove leading and trailing whitespace, or any of chars | str |
s.lstrip / s.rstrip | one side only | str |
s.replace(old, new, count=-1) | replace occurrences, optionally at most count | str |
s.find(sub) | index of first occurrence, or -1 | int |
s.index(sub) | same, but raises ValueError if absent | int |
s.startswith(p) / s.endswith(p) | prefix / suffix test; p may be a tuple | bool |
s.count(sub) | non-overlapping occurrences | int |
s.upper / .lower / .title / .capitalize | case conversion | str |
s.zfill(w) / .ljust(w,c) / .rjust / .center | pad to width w | str |
s.isdigit / .isalpha / .isspace | character-class tests | bool |
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 bytes — b[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,000 | 11.2 ms | 0.060 ms |
| 50,000 | 65.1 ms | 0.126 ms |
| 100,000 | 226.8 ms | 0.243 ms |
| 200,000 | 834.0 ms | 0.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)
"".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.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
stris 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 — useremoveprefixfor 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.
strandbytesnever mix implicitly — decode on input, encode on output, and passencoding="utf-8"explicitly.- Accumulate pieces in a list and
"".jointhem; repeated+=is quadratic whenever CPython's in-place resize does not apply.
References
- Python Software Foundation. Text Sequence Type — str.
- Python Software Foundation. Format Specification Mini-Language.
- Python Software Foundation. Unicode HOWTO.
- Smith, E. V. PEP 498 — Literal String Interpolation.
- Talin. PEP 3101 — Advanced String Formatting.
- Python Software Foundation. What’s New in Python 3.8 — f-string
=specifier. - Python Software Foundation.
re— Regular expression operations.
