Strings, bytes, encodings and formatting
str against bytes, what UTF-8 does to a string's length, mojibake and decode errors, Unicode normalisation, and the f-string format specs worth knowing.
After this lesson you can
- Encode and decode between str and bytes, and predict the byte length of UTF-8 text
- Recognise mojibake and choose an errors= policy deliberately
- Compare user text safely with normalisation and casefold
- Format numbers with f-string format specs
In Python 3, str is a sequence of Unicode code points and bytes
is a sequence of integers from 0 to 255. They never mix silently:
"a" + b"a" is a TypeError. Text becomes bytes by encoding
(text.encode("utf-8")) and bytes become text by decoding
(data.decode("utf-8")) — and the encoding must be the same on both
sides.
UTF-8 uses one byte for ASCII and two to four for everything else, so
len(text) (code points) and len(text.encode()) (bytes) differ as
soon as the text leaves ASCII. A database column or a protocol field
limited to N bytes is not limited to N characters.
def run(): words = ["cafe", "café", "€5", "🙂"] sizes = {w: [len(w), len(w.encode("utf-8"))] for w in words} try: mixed = "a" + b"a" except TypeError as e: mixed = f"TypeError: {e}" return {"chars_and_bytes": sizes, "cafe_bytes": list("café".encode("utf-8")), "mixing": mixed}When decoding goes wrong
Decoding UTF-8 bytes as Latin-1 never fails — every byte is a valid
Latin-1 character — it just produces mojibake like café. Decoding
bytes that are not valid UTF-8 as UTF-8 raises UnicodeDecodeError.
The errors= argument chooses the policy: "strict" (the default)
raises, "replace" substitutes U+FFFD (�), "ignore" drops the bad
bytes. Pick one on purpose; "ignore" loses data silently.
The same applies to files: open(path, encoding="utf-8"). Without
encoding=, Python uses the platform's preferred encoding, which is
not UTF-8 everywhere, so the same code can read a file correctly on one
machine and garble it on another.
Comparing text that looks the same
"é" can be stored as one code point (U+00E9) or as "e" followed by
a combining accent (U+0301). They render identically and compare
unequal. unicodedata.normalize("NFC", s) brings both to one form.
For case-insensitive comparison, casefold() is the thorough version
of lower(): it maps the German ß to ss, for instance.
import unicodedata def run(): data = "café".encode("utf-8") broken = b"caf\xe9" # Latin-1 bytes, not valid UTF-8 try: broken.decode("utf-8") strict = "decoded" except UnicodeDecodeError as e: strict = f"UnicodeDecodeError: {e}" composed = "é" # é as one code point decomposed = "é" # e + combining acute accent nfc = unicodedata.normalize("NFC", decomposed) return { "utf8_read_as_latin1": data.decode("latin-1"), "strict": strict, "replace": broken.decode("utf-8", errors="replace"), "ignore": broken.decode("utf-8", errors="ignore"), "lengths": [len(composed), len(decomposed)], "equal_raw": composed == decomposed, "equal_after_nfc": composed == nfc, "lower_matches": "Straße".lower() == "STRASSE".lower(), "casefold_matches": "Straße".casefold() == "STRASSE".casefold(), }Formatting with f-strings
An f-string field is {expression!conversion:format_spec}. !r uses
repr, and {x=} prints the expression's text as well as its value —
handy for debugging. The spec after : controls layout:
| Spec | Meaning |
|---|---|
:>8 / :<8 / :^8 | right / left / centre in 8 characters |
:.2f | fixed point, 2 decimals |
:, | thousands separators |
:08.3f | 3 decimals, zero-padded to width 8 |
:.1% | multiply by 100, add % |
| , |
def run(): price = 1234.5 ratio = 0.4567 name = "Ana" n = 255 return [ f"[{name:>6}] [{name:<6}] [{name:^7}]", f"{price:.2f} | {price:,.2f} | {price:010.1f}", f"{ratio:.1%}", f"{n:x} {n:b} {n:#06x}", f"{name!r} {name=}", ]To build a long string from many pieces, collect them in a list and
"".join(pieces) once, rather than growing a string with += in a
loop, which can copy the string over and over.
Common mistakes
- Measuring a byte limit with
len(text). Accented letters, most non-Latin scripts and emoji take more than one byte each. - Slicing encoded bytes and decoding the slice. A cut through the middle of a multi-byte character makes the decode fail.
- Opening text files without
encoding="utf-8"and getting different results on different machines. - Comparing user-entered text without normalising it first.
What interviewers ask
- What is the difference between
strandbytes? Code points against raw bytes;encodeanddecodeconvert, always with a named encoding. - Why can
len()of a string and of its UTF-8 encoding differ? UTF-8 is variable width: 1 byte for ASCII, up to 4 for other code points. - What is mojibake, and how do you fix it? Bytes decoded with the wrong encoding; decode with the encoding they were written in, and declare encodings explicitly at every boundary.
Exercise · Pro
Usernames that only look different
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade