Strings and text · about 40 min

Unicode, code points and bytes

Why this matters

Text is not bytes. Your program works with characters such as é, π and €, but files, network sockets and pipes carry only bytes. Every time text crosses that boundary it must be encoded (characters to bytes) or decoded (bytes to characters), and getting it wrong produces crashes like UnicodeDecodeError or garbled text like café. Python 3 makes the distinction explicit with two separate types, str and bytes. Understanding them once saves you hours of confused debugging whenever you read a file, parse network data or handle names from around the world.

Characters are numbers: code points

Computers store numbers, so every character is assigned a number. Unicode is the universal catalog: it gives each character a unique number called a code point, written U+ followed by hex digits. Some examples:

CharacterCode pointDecimal
AU+004165
aU+006197
éU+00E9233
πU+03C0960
€U+20AC8364

Unicode has room for over a million code points and covers nearly every writing system, plus symbols and emoji. The first 128 code points are identical to the old ASCII standard, which is why plain English text looks the same in both.

A Python str is a sequence of code points. len() counts code points. Two built-in functions convert between characters and their numbers:

print(ord("A"))
print(ord("é"))
print(chr(960))
print(chr(0x20AC))
print(f"{ord('€'):04X}")
65
233
π
€
20AC

ord takes a one-character string and returns its code point; chr does the reverse. 0x20AC is how you write a number in hexadecimal in Python source.

You can also write any character in a string literal by code point, using escapes: "é" is é, and "\N{GREEK SMALL LETTER PI}" is π.

Because characters are numbers, comparing strings compares code points, one position at a time. That is why "a" <= ch <= "z" tests for a lowercase ASCII letter, and also why "Zebra" < "apple" is True: uppercase Z (90) comes before lowercase a (97).

Bytes: what actually gets stored

A byte is 8 bits, a number from 0 to 255. Files and network connections are sequences of bytes. Python represents raw binary data with the bytes type, written with a b prefix:

data = b"hello"
print(data)
print(data[0])
print(len(data))
print(type(data))
b'hello'
104
5
<class 'bytes'>

Note that indexing a bytes object gives an integer (104 is h), not a one-character string. Bytes are immutable sequences of small integers. A bytes literal may contain only ASCII characters; other byte values are written with \x escapes, as in b"caf\xc3\xa9".

Encodings: from code points to bytes

An encoding is a rule for turning code points into bytes and back. Code points can be far larger than 255, so a scheme is needed. The dominant encoding today, used by the web, Linux and Python source files by default, is UTF-8. It is a variable-width encoding:

Code point rangeBytes in UTF-8Examples
U+0000 to U+007F1ASCII letters, digits
U+0080 to U+07FF2é, ñ, π, Cyrillic, Hebrew
U+0800 to U+FFFF3€, most Chinese and Japanese characters
U+10000 and up4emoji, rare scripts

ASCII text encoded as UTF-8 is byte-for-byte identical to ASCII, which made the transition painless. Multi-byte sequences use bytes 128-255 only, so a UTF-8 byte below 128 is always an ASCII character.

encode and decode

str.encode(encoding) produces bytes; bytes.decode(encoding) produces a str:

          encode("utf-8")
   str  ------------------>  bytes
 "café"                     b'caf\xc3\xa9'
        <------------------
          decode("utf-8")
word = "café"
data = word.encode("utf-8")
print(data)
print(len(word), len(data))
print(data.hex(" "))
print(data.decode("utf-8"))
b'caf\xc3\xa9'
4 5
63 61 66 c3 a9
café

é became the two bytes c3 a9. The .hex(" ") method shows each byte as two hex digits, separated by the given string, a convenient way to inspect binary data.

The key rule: decode with the same encoding that was used to encode. Bytes do not carry a label saying how they were encoded; you must know (from a file format specification, an HTTP header, or a convention like "this project uses UTF-8").

When it goes wrong

Decoding bytes with the wrong encoding either fails or silently produces garbage.

data = "café".encode("utf-8")
print(data.decode("latin-1"))
print(data.decode("ascii"))
café
Traceback (most recent call last):
  File "main.py", line 3, in <module>
    print(data.decode("ascii"))
          ~~~~~~~~~~~^^^^^^^^^
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3: ordinal not in range(128)

Latin-1 maps every byte to a character, so it never fails, but it read the two bytes of é as two separate characters, Ã and ©. This garbling is called mojibake, and you have surely seen it on web pages. ASCII decoding refuses outright, because byte 0xc3 is outside its range.

Encoding can fail too, when the target encoding has no way to represent a character:

"€5".encode("ascii")
UnicodeEncodeError: 'ascii' codec can't encode character '€' in position 0: ordinal not in range(128)

Error handlers

Both encode and decode take an errors argument that says what to do with problem characters. The default is "strict" (raise an exception). Others:

bad = b"caf\xe9"            # Latin-1 bytes, invalid as UTF-8
print(bad.decode("utf-8", errors="replace"))
print(bad.decode("utf-8", errors="ignore"))
print("€5".encode("ascii", errors="replace"))
print("€5".encode("ascii", errors="backslashreplace"))
caf�
caf
b'?5'
b'\\u20ac5'

replace substitutes the official replacement character U+FFFD (shown as �) when decoding, or ? when encoding; ignore drops the bad data silently. Use these deliberately, for example when salvaging a corrupted log file. Silently ignoring errors in important data is a way to lose information without noticing.

Where encoding happens in practice

You often do not call encode and decode yourself, but they still happen:

The healthy pattern is the "Unicode sandwich": decode bytes as early as possible when they enter your program, work only with str inside, and encode as late as possible on the way out.

A note on what "character" means

Code points are not always what a human would call one character. é can be written as one code point (U+00E9) or as e followed by a combining accent (U+0065 U+0301); both look identical, but len gives 1 and 2, and == says they differ. The unicodedata.normalize("NFC", s) function converts such text to a consistent form. Many emoji are also built from several code points. For most programs this does not matter, but it explains surprising lengths when it does.

Common mistakes

Recap

Going further: experiment with import unicodedata; print(unicodedata.name("€")), and read the Python "Unicode HOWTO" (docs.python.org, Python HOWTOs), which covers normalization and more.

Exercises

Exercise 1 · quiz

Characters or bytes?

What does this print? Answer with the two numbers separated by a space.

word = "café"
print(len(word), len(word.encode("utf-8")))
Answer

A str is a sequence of code points, and café has four: c, a, f, é. Encoded as UTF-8, the ASCII letters take one byte each but é (U+00E9) needs two bytes (0xC3 0xA9), so the bytes object has length 5. Length depends on what you count, and Python makes you be explicit about it.

Exercise 2 · write code

Show code points

Read one line of text and print one line per character, in order, of the form

<char> U+<hex code point, 4 or more uppercase hex digits> <decimal code point>

For input Aé! the output must be exactly:

A U+0041 65
é U+00E9 233
! U+0021 33

Hint for the format: f"{n:04X}" prints n in uppercase hex padded with zeros to at least 4 digits. A for loop over a string visits each character in turn; you will study loops properly in the next module, but here is the shape you need:

for ch in text:
    print(ch)

File: main.py

Starting point

text = input()
for ch in text:
    print(ch)

Example

input

Aé!

expected output

A U+0041 65
é U+00E9 233
! U+0021 33

Example

input

π€

expected output

π U+03C0 960
€ U+20AC 8364
Hint 1

ord(ch) gives the code point of a one-character string as an int.

Hint 2

Use the format spec 04X for the hex part and plain {n} for decimal.

Reference solution
text = input()
for ch in text:
    n = ord(ch)
    print(f"{ch} U+{n:04X} {n}")

Exercise 3 · write code

Bytes of a string

Read one line of text and print three lines:

  1. chars: followed by the number of characters (code points)
  2. bytes: followed by the number of bytes in its UTF-8 encoding
  3. hex: followed by the UTF-8 bytes in hex, two lowercase digits per byte, separated by single spaces

For input naïve:

chars: 5
bytes: 6
hex: 6e 61 c3 af 76 65

A bytes object has a method that produces exactly the format of line 3; look for hex in help(bytes), it accepts a separator argument.

File: main.py

Starting point

text = input()
print("chars:", len(text))

Example

input

naïve

expected output

chars: 5
bytes: 6
hex: 6e 61 c3 af 76 65

Example

input

Hi

expected output

chars: 2
bytes: 2
hex: 48 69
Hint 1

text.encode("utf-8") returns a bytes object.

Hint 2

len of a bytes object counts bytes.

Hint 3

data.hex(" ") returns the bytes as hex pairs separated by spaces.

Reference solution
text = input()
data = text.encode("utf-8")
print(f"chars: {len(text)}")
print(f"bytes: {len(data)}")
print(f"hex: {data.hex(' ')}")

Exercise 4 · write code

Shift letters with ord and chr

Read a line of text and a whole-number shift (second line, from 0 to 25). Print the text with every lowercase ASCII letter a-z shifted forward in the alphabet by that amount, wrapping around from z back to a (a Caesar cipher). All other characters, including uppercase letters, digits, spaces and punctuation, are printed unchanged.

For input hello, world! and 3:

khoor, zruog!

Build the result by starting with an empty string and adding one character per step. Use ord and chr, and % for the wraparound. You need a for loop over the characters and an if/else to decide whether a character is a lowercase letter; both are introduced fully in the next module.

File: main.py

Starting point

text = input()
shift = int(input())
result = ""
for ch in text:
    result = result + ch
print(result)

Example

input

hello, world!
3

expected output

khoor, zruog!

Example

input

xyz abc
4

expected output

bcd efg
Hint 1

"a" <= ch <= "z" compares code points, so it is true exactly for lowercase ASCII letters.

Hint 2

Turn a letter into a position 0-25 with ord(ch) - ord("a"), add the shift, take % 26, then turn it back with chr(ord("a") + position).

Reference solution
text = input()
shift = int(input())
result = ""
for ch in text:
    if "a" <= ch <= "z":
        offset = (ord(ch) - ord("a") + shift) % 26
        result = result + chr(ord("a") + offset)
    else:
        result = result + ch
print(result)

Exercise 5 · quiz

Decoding with the wrong codec

The bytes b"caf\xc3\xa9" are UTF-8 for café. What happens with b"caf\xc3\xa9".decode("ascii")?

Answer
  • It raises UnicodeDecodeError, because 0xc3 is not a valid ASCII byte

ASCII only defines bytes 0 to 127, and 0xc3 is 195, so strict decoding fails with UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3. Decoding with the wrong but permissive codec Latin-1 would instead give the garbled café (called mojibake). Passing errors="replace" substitutes the replacement character U+FFFD for bad bytes instead of raising.