Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 41 of 899 min

Text that survives: encodings, and why the accents turned into question marks

Two different things called text

A str in Python 3 is a sequence of characters. A bytes object is a sequence of numbers from 0 to 255. Files, networks and disks hold bytes. Your program holds characters. An encoding is the rule for converting between them.

python
"नमस्ते".encode("utf-8")      # b'\xe0\xa4\xa8\xe0\xa4\xae...'
b"hello".decode("utf-8")     # 'hello'

Every file you open involves that conversion, whether or not you name it. Naming it is the whole lesson.

Always pass encoding="utf-8"

python
open(path, encoding="utf-8")
path.read_text(encoding="utf-8")

Without it, Python uses the platform's default. On Linux and modern macOS that is UTF-8 and everything works. On Windows it has historically been the system code page — cp1252 in Western Europe, cp1251 in Russia, and so on — which cannot represent most of the world's writing systems.

The consequence is a script that works on the developer's Mac, is emailed to a colleague on Windows, and fails there with:

UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d in position 1247

or, worse, does not fail and writes नमसॠinto the output. Passing encoding="utf-8" explicitly removes the entire class of problem, and it is eight words.

What mojibake actually is

नमस्ते displayed as नमसà¥à¤¤à¥‡ is text that was encoded as UTF-8 and then decoded as if it were Latin-1. The bytes are intact; the interpretation is wrong. That is good news — the data is usually recoverable:

python
broken.encode("latin-1").decode("utf-8")

If that produces sense, you have found the mistake. The free ftfy package automates this and handles the double-encoded cases.

The word café, written one way and read anotherRead as UTF-8Read as cp1252Written UTF-8Written cp1252caféwhat you wantedcafémojibakeDecode error0xE9 is not UTF-8caféno Devanagari at allThe bytes are never damaged by being read with the wrong label, which is why mojibake is recoverable:text.encode("latin-1").decode("utf-8") gives it back. Question marks are a different failure — thosebytes were replaced on the way out and are gone. Passing encoding="utf-8" to every open removes thewhole square.
The word café, written one way and readanotherRead as UTF-8Read as cp1252Written UTF-8caféwhat you wantedcafémojibakeWritten cp1252Decode error0xE9 is not UTF-8caféno Devanagari at allThe bytes are never damaged by being read with thewrong label, which is why mojibake is recoverable:text.encode("latin-1").decode("utf-8") gives itback. Question marks are a different failure — thosebytes were replaced on the way out and are gone.Passing encoding="utf-8" to every open removes thewhole square.

The reverse case, where the bytes were genuinely replaced by ? on the way out, is not recoverable. That happens when text is encoded to something that cannot represent it with errors="replace", and it is why you should let an encode fail loudly rather than papering over it.

The spreadsheet problem

Excel on Windows opens a UTF-8 CSV as if it were the local code page unless the file begins with a byte order mark. So for a file a colleague will open in a spreadsheet:

python
path.write_text(csv_text, encoding="utf-8-sig")

utf-8-sig writes three extra bytes at the start. Reading a file that has them with plain utf-8 leaves a stray \ufeff glued to the first column name, which produces the mystifying KeyError: 'id' when the key is visibly id. Reading with encoding="utf-8-sig" strips the mark if present and does nothing if not, so it is the safer choice for reading anything that came from a spreadsheet.

len does not count what you see

python
len("café")          # 4
len("👍")            # 1
len("👨‍👩‍👧")        # 5
len("नमस्ते")         # 6

len counts code points, not what a reader would call characters. The family emoji is three people joined by two invisible joiner characters. नमस्ते is six code points forming four visible clusters, because vowel signs combine with the consonant before them.

This matters whenever you truncate. Slicing a string at 100 characters can cut a Devanagari syllable in half or split an emoji into its components, producing something that renders as nonsense. For display truncation on non-Latin text, count grapheme clusters with the free regex package (regex.findall(r"\X", s)) rather than slicing raw.

It also matters for token counting with language models, which is a third unit again — not characters, not code points, not words. Module seven takes that up.

The same text, two byte sequences

é can be one code point, or e followed by a combining accent. They look identical and compare as unequal:

python
import unicodedata
a = "café"
b = unicodedata.normalize("NFD", a)
a == b                                    # False
len(a), len(b)                            # 4, 5
unicodedata.normalize("NFC", b) == a      # True

macOS stores filenames in the decomposed form and Linux in the composed one, so a filename copied between them can fail to match. Normalise to NFC on the way in, once, whenever you compare or deduplicate user-supplied text.

When you genuinely do not know the encoding

python
raw = path.read_bytes()
raw[:200]                    # look at it

Then guess in order: UTF-8, then utf-8-sig, then cp1252, then the likely regional encoding. The free charset-normalizer package guesses statistically and is right most of the time, but it is a guess — encodings are not recorded in the file, so no tool can be certain. Ask the sender if you can. Record the answer next to the data, because the next person will need it too.

The one thing to keep

Pass `encoding="utf-8"` to every open, because the platform default is not UTF-8 everywhere, and remember that `len` counts code points rather than the characters a reader sees.

Before you move on

A CSV exported from a spreadsheet is read with `encoding="utf-8"` and every lookup of the first column raises KeyError, though printing the header shows the expected name. What is happening?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly