Strings and Formatting
A Python string is an immutable sequence of Unicode code points. Both halves of that sentence have consequences: immutability decides how you build strings efficiently, and Unicode decides what len means.
Immutable, and what that costs
greeting = "hello"
upper = greeting.upper()
print(greeting, upper) # hello HELLO - the original is untouched
# greeting[0] = "H" # TypeError: 'str' object does not support
# item assignment
Open this example in the editor and change it. Nothing you type is saved.
Every string method returns a new string. That is why building one in a loop with += is wasteful - each round allocates a whole new string and copies everything so far:
import time
words = ["word"] * 20000
start = time.perf_counter()
out = ""
for w in words:
out += w # quadratic: copies the accumulated string
slow = time.perf_counter() - start
start = time.perf_counter()
out = "".join(words) # one pass, one allocation
fast = time.perf_counter() - start
print(len(out))
print("join is faster:", fast < slow)
Open this example in the editor and change it. Nothing you type is saved.
CPython has an optimisation that sometimes makes += on strings fast, but it only applies when the string has exactly one reference, so it disappears the moment the code changes slightly. join is always right.
f-strings
name = "Aisha"
score = 0.8637
count = 7
print(f"{name} scored {score:.1%} over {count} attempts")
# Aisha scored 86.4% over 7 attempts
print(f"{score:.2f}") # 0.86 - two decimal places
print(f"{count:03d}") # 007 - zero-padded
print(f"{count:>6}|") # ' 7|' - right-aligned in 6
print(f"{name:<10}|") # 'Aisha |' - left-aligned
print(f"{name:^11}|") # ' Aisha |' - centred
print(f"{1234567:,}") # 1,234,567 - thousands separators
print(f"{1234567:_}") # 1_234_567
print(f"{255:#x} {255:#b}") # 0xff 0b11111111
print(f"{3.14159:>10.2f}|") # ' 3.14|' - align and precision together
Open this example in the editor and change it. Nothing you type is saved.
The = suffix prints the expression as well as the value, which is a debugging shortcut worth knowing:
total = 42
print(f"{total=}") # total=42
print(f"{total * 2=}") # total * 2=84
Open this example in the editor and change it. Nothing you type is saved.
Any expression is allowed, including a call, and the width can itself be a variable:
name = "Aisha"
width = 12
print(f"{name.upper():>{width}}|") # ' AISHA|'
print(f"{sum([1, 2, 3])}") # 6
from datetime import date
print(f"{date(2026, 10, 7):%d %B %Y}") # 07 October 2026
Open this example in the editor and change it. Nothing you type is saved.
Two gotchas. A literal brace is doubled: f"{{literal}}" prints {literal}. And in Python 3.11 an f-string cannot contain the same quote character as the one that delimits it, so f"{d["key"]}" is a syntax error - use different quotes, or pull the lookup out into a variable.
The three formatting styles, and which to use
name, score = "Aisha", 0.8637
print(f"{name}: {score:.1%}") # f-string
print("{}: {:.1%}".format(name, score)) # str.format
print("%s: %.1f%%" % (name, score * 100)) # percent, legacy
Open this example in the editor and change it. Nothing you type is saved.
Use f-strings. The exceptions are a template that must be stored or translated - where str.format lets you keep the string separate from the values - and logging, where the percent style is genuinely better:
import logging
logging.getLogger(__name__).info("saved %s rows for %s", 12, "aisha")
Open this example in the editor and change it. Nothing you type is saved.
The logging call defers formatting until the message is actually emitted, so a debug line in a hot loop costs almost nothing when debug logging is off. An f-string there would be formatted every time regardless.
Splitting and joining
line = " aisha , noshad ,sam "
print(line.split(",")) # [' aisha ', ' noshad ', 'sam ']
print([p.strip() for p in line.split(",")]) # ['aisha', 'noshad', 'sam']
text = "one two three"
print(text.split()) # ['one', 'two', 'three']
print(text.split(" ")) # ['one', '', 'two', '', '', 'three']
Open this example in the editor and change it. Nothing you type is saved.
split() with no argument splits on any run of whitespace and drops empties - which is what you want for prose, and not what split(" ") does.
header = "Content-Type: text/html; charset=utf-8"
key, sep, value = header.partition(":")
print(repr(key), repr(sep), repr(value))
# 'Content-Type' ':' ' text/html; charset=utf-8'
missing = "no colon here"
print(missing.partition(":")) # ('no colon here', '', '')
Open this example in the editor and change it. Nothing you type is saved.
partition splits once and always returns three parts, so it never raises and never needs a length check. rpartition does the same from the right, which is how you split a filename from its extension without a regular expression.
print("a,b,c".split(",", 1)) # ['a', 'b,c'] - at most one split
print("a,b,c".rsplit(",", 1)) # ['a,b', 'c'] - from the right
Open this example in the editor and change it. Nothing you type is saved.
The other methods you will reach for:
path = " /var/log/app.log "
print(path.strip()) # '/var/log/app.log'
print(path.strip().removeprefix("/var/")) # 'log/app.log'
print(path.strip().removesuffix(".log")) # '/var/log/app'
print("Hello".startswith(("He", "Ho"))) # True - a tuple of options
print("a-b-c".replace("-", "+", 1)) # 'a+b-c' - count limits it
print("Hello World".casefold()) # 'hello world' - for comparing
Open this example in the editor and change it. Nothing you type is saved.
Use casefold() rather than lower() when comparing strings for equality across languages; it handles cases like the German sharp s that lower() does not.
Characters, bytes and encodings
A string is code points. A file, a socket and a database column hold bytes. encode and decode are the border between them, and crossing it implicitly is where mojibake comes from.
text = "cafe" + chr(0x301) + " nai" + chr(0x308) + "ve" # combining accents
simple = "caf" + chr(0xe9) # precomposed e-acute
print(len(simple)) # 4 - one code point for the accent
utf8 = simple.encode("utf-8")
print(len(utf8)) # 5 - the accent takes TWO bytes
print(list(utf8)) # [99, 97, 102, 195, 169]
print(utf8.decode("utf-8")) # café - back to a string
print(utf8.decode("latin-1")) # café - mojibake, silently
try:
utf8.decode("ascii")
except UnicodeDecodeError as error:
print(type(error).__name__) # UnicodeDecodeError
Open this example in the editor and change it. Nothing you type is saved.
Decoding with the wrong codec is sometimes an error and sometimes a silent mess - latin-1 can decode any byte sequence, so it never complains and is never right for UTF-8 data. Always name the encoding explicitly when you open a file:
# open(path, encoding="utf-8") - not the platform default
Open this example in the editor and change it. Nothing you type is saved.
The platform default differs between Linux and Windows, which is how a program that works on one developer's machine produces garbage on another's.
Normalisation matters when comparing text from different sources:
import unicodedata
composed = "caf" + chr(0xe9) # one code point
decomposed = "cafe" + chr(0x301) # 'e' plus combining accent
print(composed == decomposed) # False - different code points
print(len(composed), len(decomposed)) # 4 5
print(unicodedata.normalize("NFC", decomposed) == composed) # True
Open this example in the editor and change it. Nothing you type is saved.
Two strings that render identically can compare unequal. Normalise to NFC before storing or comparing user-entered text.
A worked example
import unicodedata
RAW = """
2026-10-01 | Aisha Khan | aisha@example.com | 1999
2026-10-02 | Noshad Alam | NOSHAD@EXAMPLE.COM | 450
2026-10-02 | Sam O'Neill | sam@example.com | 1299
bad line with no pipes
2026-10-03 | Leah Brown | leah@example.com | not-a-number
"""
def parse(line):
"""Return a record, or None, without raising on malformed input."""
parts = [p.strip() for p in line.split("|")]
if len(parts) != 4:
return None
day, name, email, amount = parts
if not amount.isdigit():
return None
return {
"day": day,
"name": unicodedata.normalize("NFC", name),
"email": email.casefold(),
"pence": int(amount),
}
records = [r for r in (parse(line) for line in RAW.strip().splitlines()) if r]
for r in records:
print(f"{r['day']} {r['name']:<14} {r['email']:<22} {r['pence'] / 100:>8,.2f}")
print("-" * 56)
total = sum(r["pence"] for r in records)
print(f"{'total':<38} {total / 100:>8,.2f}")
print(f"{len(records)} of {len(RAW.strip().splitlines())} lines parsed")
# The two lines that did not parse are the point: partition and isdigit
# let the parser reject them without a try block and without a regex.
print()
print("emails, deduplicated and sorted:")
print(", ".join(sorted({r["email"] for r in records})))
# And a report line built with join rather than +=.
columns = ["day", "name", "email", "pence"]
print(" | ".join(c.upper().center(10) for c in columns))
Open this example in the editor and change it. Nothing you type is saved.
Three habits are on display. isdigit() validates before int() rather than catching the exception afterwards - cheaper, and the intent is clearer. casefold() normalises the email so two spellings of the same address deduplicate. And the final report line uses join over a generator rather than accumulating with +=.
When it goes wrong
| Symptom | Cause |
|---|---|
| Building a string in a loop is slow | += is quadratic; use join |
UnicodeDecodeError reading a file |
Wrong encoding, or no encoding= given |
Text came out as é instead of an accent |
UTF-8 bytes decoded as latin-1 |
| Two identical-looking strings are unequal | Different normalisation; use NFC |
len disagrees with the visible characters |
Code points are not grapheme clusters |
split(" ") produced empty strings |
Use split() for runs of whitespace |
f"{d["k"]}" is a syntax error |
Nested same quotes; use different ones |
| A debug log line is slow when debug is off | An f-string formats eagerly; pass args to the logger |
A check you can run
s1 = "caf" + chr(0xe9)
s2 = "cafe" + chr(0x301)
print(s1, s2)
print(s1 == s2, len(s1), len(s2))
Open this example in the editor and change it. Nothing you type is saved.
Both print the same word. They are not equal, and their lengths differ. Any time you compare, deduplicate or use as a dictionary key text that came from a user, a web form or a file, that difference is waiting - and unicodedata.normalize("NFC", text) at the boundary is the one line that removes it.