Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Files, Paths & Encodings  ›  Lesson

Unicode, str and bytes

Unicode and Encodings 24 minText is code points; files are bytes; an encoding is the table between them
You're building a piece ofQuarry — the yard
This piece — decode_source(): Works out which encoding a file actually uses and turns its bytes into text.
Scenario One export in the yard came from a Windows spreadsheet, another from a Mac with a byte-order mark, and a third from a sensor that writes UTF-16. Quarry must hand the next stage the right text for all three.
Your task
Build decode_source(hex_data). The bytes arrive as a hex string; turn them into bytes with bytes.fromhex. Then pick the encoding in this order: a UTF-8 byte-order mark (EF BB BF) means "utf-8-sig"; a UTF-16 mark (FF FE or FE FF) means "utf-16"; otherwise the first of "utf-8" then "cp1252" that decodes without error; and "latin-1" as the last resort, because it never fails. Return {"encoding": ..., "text": ...}. Example: decode_source("636166e9") → {"encoding": "cp1252", "text": "café"}.

Unicode, str and bytes

In short: a file never contains text — it contains bytes, and text is one interpretation of them. str is a sequence of Unicode code points; bytes is a sequence of integers 0–255, what a disk actually holds. Encoding goes str → bytes; decoding goes bytes → str. A wrong guess fails two ways: loudly, raising UnicodeDecodeError when the bytes aren't legal in that encoding, or silently — the bytes happen to be legal in the wrong encoding too, and you get confidently wrong characters with no error at all (mojibake). UTF-8 is the default to reach for: it's ASCII-compatible, has no byte-order question, and spends 1 byte on ASCII, more on everything else. Nothing in a file says which encoding it used — some tools add a byte-order mark as a hint, but otherwise you have to work it out from the bytes themselves.

The same two bytes, C3 A9 — decoded with UTF-8 they read back as "é" (one correct character); decoded with Latin-1 they read back as "é" (two wrong characters, no error raised either way)

Decode a Windows export that isn't UTF-8

A spreadsheet exported from a Windows machine often uses cp1252, not UTF-8. UTF-8 has strict rules about which bytes can follow which, so most wrong guesses crash loudly:

broken — fix itUnicodeDecodeError

Read the error — it names the byte it choked on and its position. Then decode with the encoding that was actually used.

But the dangerous case is quieter. The bytes happen to be valid in the wrong encoding too, and nothing fails — you just get the wrong text:

What does this print?
print("café".encode("utf-8").decode("latin-1"))

Once you've seen é, ï and ’, you'll recognise them forever — UTF-8 bytes read as a single-byte Western encoding. Quarry's decoder tries the strongest evidence first: utf-8, then cp1252 (the most common non-UTF-8 export), falling back to latin-1 as a last resort — because Latin-1 maps every one of the 256 byte values to some character, so it can never raise. That's exactly why it's last, not first: never crashing is not the same as being right, and a guess should be visible, not silent.

You can also choose what a decode does with bytes that don't fit, through errors=:

python

replace is honest — the � marks exactly where information was lost, and you can count them. ignore quietly deletes data and should make you nervous every time you reach for it.

Watch a string's byte cost, character by character

UTF-8 is a variable-width code: ASCII costs one byte, and everything above it costs more. Step through a string one character at a time and watch the byte cost of each:

step through it
1word = "₹5 ok"
2rows = []
3for ch in word:
4 code = ord(ch)
5 size = len(ch.encode("utf-8"))
6 rows.append((ch, hex(code), size))
7total = sum(size for _, _, size in rows)
8print(total)

Five characters, seven bytes: the rupee sign alone costs three. That's why len() of a str and len() of the bytes it encodes to can disagree — one counts code points, the other counts bytes.

Strip a byte-order mark from a Mac export

A stray  glued to the first column name is a BOM: the bytes EF BB BF, which some tools put at the start of a UTF-8 file to announce "this is UTF-8". Decoded as plain utf-8 it survives as an invisible character; the utf-8-sig codec reads it and throws it away:

python

UTF-16 needs its mark more, because it has a real question to answer: is each two-byte unit stored low byte first (FF FE) or high byte first (FE FF)? The utf-16 codec reads the mark and picks the order for you — trust it before anything else when it's there:

What does this print?
data = b"\xff\xfe\x68\x00\x69\x00"
print(data.decode("utf-16"))

Compare two spellings of the same accented name

One last surprise: é has two spellings in Unicode — the single code point U+00E9, or a plain e followed by U+0301, a combining acute accent. They render identically and == says they differ:

What does this print?
a = "é"     # e + combining accent
b = "é"      # é as one code point
print(a == b, len(a), len(b))

unicodedata.normalize("NFC", s) composes a string into one canonical spelling, and casefold() is lower() built for comparison — it also maps ß to ss:

python

Quick practice

What does this print?
print(len("naïve".encode("utf-8")))
broken — fix itUnicodeEncodeError

Run it and read the UnicodeEncodeError. Then encode with a codec that can hold the character.

What does this print?
print("STRASSE".casefold() == "strasse".casefold(), "straße".casefold())
What does this print?
data = "hé".encode("utf-8")
cleaned = data.decode("utf-8", errors="ignore")
print(cleaned == "hé", len(cleaned))

🎯 Your turn

Build decode_source(hex_data):

  • decode_source("636166e9") → {"encoding": "cp1252", "text": "café"}
  • decode_source("efbbbf636166c3a9") → {"encoding": "utf-8-sig", "text": "café"}

Hint — check the BOMs with startswith, then try utf-8 and cp1252 in a try/except UnicodeDecodeError, and fall back to latin-1.

Then press ▶ Run and hit ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upCharacters versus bytes

Write byte_lengths(text) returning [characters, utf8_bytes, utf16_bytes], where the UTF-16 count uses "utf-16-le" (no byte-order mark). byte_lengths("é") → [1, 2, 2].

text-encodingutf-8
DrillSee the bytes

Write to_hex(text, encoding) returning the encoded bytes as lowercase hex pairs separated by single spaces, or "unencodable" if the encoding can't represent the text. to_hex("hé", "utf-8") → "68 c3 a9".

text-encodingencode-decode
BuildUndo a double decode

Somebody decoded UTF-8 bytes as cp1252, so café became café. Write repair(text): re-encode with "cp1252" and decode with "utf-8". If either step fails, the text wasn't mojibake of that kind — return it unchanged.

text-encodingmojibake
BossEqual to a human, unequal to ==

"é" can be one code point or e plus a combining accent; they look identical and == says False. Write same_text(a, b) that returns True when the two are equal after unicodedata.normalize("NFC", ...) and .casefold().

text-encodingunicode-normalization
CapstoneLabel every sample

Write sniff_all(samples) taking {name: hex} and returning {name: label}. Labels, checked in order: "utf-8-sig" (UTF-8 BOM), "utf-16" (either UTF-16 BOM), "binary" (contains a 00 byte), "ascii" (every byte below 0x80), "utf-8" (strict decode works), "cp1252" (that decode works), else "unknown".

text-encodingbomdict
ExplainWhy "just use latin-1" is a trap

A teammate notices that decoding with "latin-1" never raises, and proposes Quarry decode every file that way so it never crashes on bad input. Explain why it never raises, what it does to a UTF-8 file, and why a crash can be the better outcome.

decode_source.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
decode_source(hex_data) → dictThe encoding the bytes really use, and the text they decode to.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.