Encode and decode are verbs¶
Level: 101 · for anyone starting from zero
One line: Encoding turns text into bytes and decoding turns bytes back into text, each under a named table — and nearly every bug in this field is one of those two verbs applied with the wrong table, or applied twice.
Two nouns¶
There are only two kinds of thing in this subject, and keeping them apart is most of the work.
| On the text side | On the bytes side | |
|---|---|---|
| What it is | characters, code points | bytes, each 0–255 |
| Python | str |
bytes |
| Rust | String / &str |
Vec<u8> / &[u8] |
| ABAP | string |
xstring |
What len counts |
characters | bytes |
An encoding is the table that maps between the two columns. 'café' is four characters on the left and, in UTF-8, five bytes on the right; in Latin-1 it is four bytes; in UTF-16 it is eight. Same message, three tables, three different files.
Two verbs¶
Encode goes left to right: text → bytes, ready to be written, sent, or hashed. Decode goes right to left: bytes → text, so the program can work in characters. Every text file you have ever read was decoded, and every one you have written was encoded, whether or not the program said so out loud.
Both verbs take the table as an argument. That is the sentence the rest of this chapter hangs on: the table is not carried by the data, it is not checked against the data, and nothing anywhere confirms that the table you passed on the way in is the one somebody else passed on the way out. A file does not record which rulebook was used — so both verbs are always, in the end, acting on your say-so.
There is one small mercy: the two verbs fail differently. Encoding fails when the table has no slot for a character you have (é into ASCII); decoding fails when the bytes are not shaped the way the table expects (e9 alone, read as UTF-8). Both raise, both name the position — and both are far better than the third case, which is a table that simply cannot fail. Latin-1 maps all 256 byte values, so decoding under it always succeeds and returns something. That is what makes it the right tool for carrying arbitrary bytes through a string, and the wrong tool for detecting anything at all: it answers "fine" to every file you show it.
The sandwich¶
The rule that follows is old and short: decode at the edge coming in, work in text in the middle, encode at the edge going out. The edges are the file, the socket, the database driver, the terminal, the RFC call — the places bytes meet your program. Between them, nothing should be bytes.
The cost of skipping it shows up as two bugs that both work until the first é. Slice bytes when you meant characters and you cut a character in half. Size a column in bytes when you meant characters — a CHAR(4) for a four-character name — and it overflows on the first accented one. Chapter 7's interface bugs are all one of those two lines being the one the program did not mean.
In Python¶
The 3×3 grid in section 3 is the whole lesson: one string, three tables, both verbs, every combination. Three of the nine outcomes are correct, and they are correct for exactly one reason — the reader was told the table the writer used.
Verified output of encode_and_decode_are_verbs_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. TWO NOUNS
------------------------------------------------------------------------
text 'café' type str len 4 <- characters
bytes b'caf\xc3\xa9' type bytes len 5 <- bytes
Two different types holding the same message. len() answers a
different question on each, and they disagree the moment a
character needs more than one byte.
2. TWO VERBS, AND THE ARGUMENT NOTHING CHECKS
------------------------------------------------------------------------
'café'.encode('utf_8') -> b'caf\xc3\xa9'
b'caf\xc3\xa9'.decode('utf_8') -> 'café'
encode: text -> bytes, under a table
decode: bytes -> text, under a table
The table is an ARGUMENT. It is not a property of the bytes, it is
not stored with them, and no check anywhere confirms that the table
you passed is the table the writer used.
3. THE 3x3 GRID: ONE STRING, THREE TABLES, BOTH VERBS
------------------------------------------------------------------------
'café'.encode('utf_8' ) = 63 61 66 c3 a9
'café'.encode('latin_1' ) = 63 61 66 e9
'café'.encode('utf_16_le') = 63 00 61 00 66 00 e9 00
bytes written as utf_8:
read as utf_8 'café' <- correct
read as latin_1 'café'
read as utf_16_le UnicodeDecodeError at byte 4: truncated data
bytes written as latin_1:
read as utf_8 UnicodeDecodeError at byte 3: unexpected end of data
read as latin_1 'café' <- correct
read as utf_16_le '慣\ue966'
bytes written as utf_16_le:
read as utf_8 UnicodeDecodeError at byte 6: invalid continuation byte
read as latin_1 'c\x00a\x00f\x00é\x00'
read as utf_16_le 'café' <- correct
Three of the nine are right, and they are right for one reason only:
the reader was told the same table the writer used. Of the other six,
three raise and three do not -- and the three that do not are worse.
4. WHY latin-1 CAN NEVER RAISE
------------------------------------------------------------------------
of the 256 one-byte inputs, latin_1 decodes 256
of the 256 one-byte inputs, utf_8 decodes 128
of the 256 one-byte inputs, ascii decodes 128
latin-1 maps all 256 byte values to code points 0-255, so decoding
under it CANNOT fail. That makes it the right tool for carrying
arbitrary bytes through a str, and the wrong tool for detecting
anything at all: it answers 'fine' to every file you show it.
5. THE TWO ERRORS, FIELD BY FIELD
------------------------------------------------------------------------
b'caf\xe9!'.decode('utf_8')
.encoding 'utf-8' the table you passed
.start 3 the byte it stopped on
.object b'\xe9' that byte
.reason 'invalid continuation byte'
'café'.encode('ascii')
.encoding 'ascii' the table you passed
.start 3 the character it stopped on
.object 'é' that character
.reason 'ordinal not in range(128)'
Both name the table, the position and the reason. Read the position
first: it tells you which character in the file to look at, and a
hex dump of that offset usually ends the argument.
6. THE UNICODE SANDWICH, AND THE COST OF SKIPPING IT
------------------------------------------------------------------------
decode at the edge coming in -> work in text -> encode at the edge going out
Slicing text and slicing bytes are not the same operation:
'café'[:4] = 'café'
b'caf\xc3\xa9'[:4] = b'caf\xc3' <- half of a character
and decoding that: UnicodeDecodeError at byte 3: unexpected end of data
A four-CHARACTER field and a four-BYTE column are different sizes:
len('café') = 4 characters
len('café'.encode()) = 5 bytes
Every interface bug in chapter 7 is one of those two lines being
the one the program did not mean.
In the terminal¶
iconv is the two verbs written out as flags: -f is the decode, -t is the encode. That makes a pipe the clearest place to see that the table is an argument you supply and nothing validates — step 3 produces mojibake deliberately, and reports success.
Verified output of encode_and_decode_are_verbs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. A PIPE CARRIES BYTES. THAT IS ALL IT CARRIES.
------------------------------------------------------------------------
$ printf 'caf\xc3\xa9' | xxd
00000000: 6361 66c3 a9 caf..
five bytes. Nothing on this pipe records that they are UTF-8,
or text at all. The next command has to be told.
2. iconv IS THE TWO VERBS, WRITTEN AS FLAGS
------------------------------------------------------------------------
$ printf 'caf\xc3\xa9' | iconv -f UTF-8 -t ISO-8859-1 | xxd
00000000: 6361 66e9 caf.
-f UTF-8 decode these bytes under this table -> café
-t ISO-8859-1 encode that text under this table -> 4 bytes
Same message, one byte shorter, because Latin-1 spends one byte
on é where UTF-8 spends two.
3. NOTHING CHECKS -f AGAINST THE DATA
------------------------------------------------------------------------
$ printf 'caf\xc3\xa9' | iconv -f ISO-8859-1 -t UTF-8 | xxd
00000000: 6361 66c3 83c2 a9 caf....
iconv exit 0 -- no complaint whatsoever.
The same five bytes, decoded under the WRONG table and re-encoded:
seven bytes now, and c3 a9 has become c3 83 c2 a9. That is mojibake,
made deliberately, with a command that reported success.
4. AND BACK, BECAUSE LATIN-1 THREW NOTHING AWAY
------------------------------------------------------------------------
$ ... | iconv -f UTF-8 -t ISO-8859-1 | xxd
00000000: 6361 66c3 a9 caf..
63 61 66 c3 a9 -- the five bytes we started with.
The damage in step 3 was reversible because every byte survived it.
That is not true of every mistake, and which ones reverse is the
next lesson.
In Rust¶
Rust makes the two directions deliberately unequal. Encoding takes no argument at all, because a String has exactly one encoding and the type says so; decoding is a Result you are made to look at. And the table Rust does not ship is the interesting one — Latin-1 is a single line to write, cannot fail, and is the mojibake mechanism spelled out in code.
Verified output of encode_and_decode_are_verbs_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. TWO NOUNS, AND THE TABLE IS PART OF THE TYPE
------------------------------------------------------------------------
let text: &str = "café";
text.len() = 5 <- BYTES, always
text.chars().count() = 4 <- characters
text.as_bytes() = [63, 61, 66, c3, a9]
`&str` does not mean 'text'. It means 'bytes that are valid
UTF-8', and that promise is the type. There is no second kind
of String holding Latin-1, so no String ever has to say which
table it is -- which is why .len() can be bytes without lying.
2. ENCODING TAKES NO ARGUMENT
------------------------------------------------------------------------
text.as_bytes() -> [63, 61, 66, c3, a9]
text.to_string().into_bytes() -> same bytes, no copy of the data
Python's .encode() needs a table and defaults to UTF-8. Rust
has nothing to pass: the bytes under a String ALREADY are the
UTF-8 ones. Encoding here is not a conversion, it is a cast of
the reader's attention from characters to the bytes beneath.
3. DECODING IS A Result, AND YOU ARE MADE TO LOOK AT IT
------------------------------------------------------------------------
String::from_utf8([63, 61, 66, c3, a9])
Ok("café")
String::from_utf8([63, 61, 66, e9])
Err: valid up to byte 3, error_len None
from_utf8_lossy -> "caf�"
valid_up_to() is the offset to put in the bug report: everything
before it decoded, and the byte at it is the one to look at in a
hex dump. from_utf8_lossy never fails -- it substitutes U+FFFD,
which means the damage is now IN the string and the original
byte is gone.
4. THE TABLE RUST DOES NOT SHIP
------------------------------------------------------------------------
the same bytes [63, 61, 66, c3, a9], read under the other table:
as UTF-8 "café"
as Latin-1 "café" <- one line, and it cannot fail
fn decode_latin1(b: &[u8]) -> String { b.iter().map(|&b| b as char).collect() }
That is the entire Latin-1 decoder: every byte IS its code point.
It has no error case, so it accepts every file ever written and
returns something. Std does not ship it, and the reason is not
that it is hard -- it is that a decoder which cannot fail is a
decoder that cannot warn you.
5. WHERE THE TWO VERBS LIVE IN A RUST PROGRAM
------------------------------------------------------------------------
fs::read_to_string(p) -> io::Result<String> decodes; fails on bad UTF-8
fs::read(p) -> io::Result<Vec<u8>> does not decode
String::from_utf8 -> Result<String, _> the decode, made explicit
str::as_bytes -> &[u8] the encode, free
The sandwich is not advice here, it is the signatures: to get a
String out of bytes you pass through a Result, and the compiler
will not let you forget which side of the boundary you are on.
If you are coming from Python or ABAP¶
Python. You already have the two types; what is worth internalising is that .encode() and .decode() are the only doors between them, and that their default table is not a fact about your file. open(path) decodes, and which table it uses depends on the machine unless you say encoding='utf-8' — so a program that works on your laptop and fails in the container has usually not changed at all. The errors= argument is the other thing to know cold: strict raises, replace writes U+FFFD, ignore drops the byte, and surrogateescape smuggles it through so it can be written back byte-identical. Only the first tells you the truth, and only the last is reversible.
ABAP. The split is a type split here too, and a sharper one: string is characters and xstring is bytes, and there is no operator that quietly mixes them. cl_abap_codepage=>convert_to( ) is encode and convert_from( ) is decode; a table that cannot hold the character raises cx_sy_conversion_codepage. Two differences from Python that matter in practice. First, the boundary is usually a file or an RFC, not a function call — OPEN DATASET … IN LEGACY TEXT MODE CODE PAGE … is where the table gets named, and naming the wrong one there is the ABAP form of every bug on this page. Second, a Unicode ABAP system stores string in a fixed-width UTF-16 form internally, so strlen( ) counts neither bytes nor UTF-8 units; check the actual code page on your own system rather than trusting a number from a document, including this one. (Not machine-checked — CI cannot run ABAP.)
Try it¶
cd 03_Encodings/encode_and_decode_are_verbs/examples
python3 encode_and_decode_are_verbs_py.py
bash encode_and_decode_are_verbs_sh.sh
rustc --edition 2024 encode_and_decode_are_verbs_rs.rs -o /tmp/verbs && /tmp/verbs
Without the machine: you are told a file holds the four bytes 63 61 66 e9. Somebody asks you what it says. What is the honest answer, and what would you need in order to give a better one? Then: the same file, but the bytes are 63 61 66 c3 a9 — has the question changed?
Practice¶
Name the shape of each bug. For S = "café" and B = S.encode(), predict what each line gives — a round trip, mojibake, a raised error, or something lossy — before running any of it:
B.decode('utf-8')
B.decode('latin-1')
B.decode('ascii')
S.encode('latin-1')
B.decode('latin-1').encode('utf-8')
Then the question underneath: two of those five fail loudly and two succeed wrongly. Say which is which, and why the last one is the one that ruins a database rather than an afternoon.
Answers
Verified output of encode_and_decode_are_verbs_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
start with 'café' -> utf-8 bytes 63 61 66 c3 a9
B.decode('utf-8') round trip str 'café'
B.decode('latin-1') wrong table str 'café'
B.decode('ascii') table too small UnicodeDecodeError
S.encode('latin-1') works, and is lossy later bytes b'caf\xe9'
B.decode('latin-1').encode() decoded, re-encoded bytes b'caf\xc3\x83\xc2\xa9'
Line 1 is the only one that is a round trip: same table both ways.
Line 2 is MOJIBAKE, and notice that nothing failed. Latin-1 has a
character for all 256 bytes, so it can never raise -- it just tells you
the wrong thing, quietly, with an exit status of zero.
Line 3 is the good failure. ASCII has no character for c3, so the decode
stops and says which byte and where. A table that CAN fail is a feature.
Line 4 succeeds and is the trap underneath a lot of legacy data: 'café'
really does fit in Latin-1, so nothing warns you -- until the next
string has a character Latin-1 has never heard of.
Line 5 is DOUBLE ENCODING, and this is the one that ruins databases.
5 bytes in, 7 bytes out: 63 61 66 c3 83 c2 a9
The text was decoded with the wrong table and then honestly encoded
with the right one, so the mojibake is now CORRECT UTF-8 of the wrong
characters. It round-trips perfectly from here on, which is exactly
why it survives every later check and reaches the user.
The verbs, one more time. ENCODE goes text -> bytes. DECODE goes bytes
-> text. Each needs a named table, and neither can guess it: the bytes
do not carry it.
See also¶
- UTF-8 by hand — the table this page keeps passing as an argument, done with a pencil
- Mojibake — the wrong argument, and how to read the culprit off the screen
- Validation is a boundary — who checks the decode, and what is left of the check afterwards
- Code pages — where all the other tables came from
strvsbytes— the same two nouns, in Python detail- Anatomy of a
String↗ — the Rust side, in the sibling library - UTF-8 everywhere — the rule that makes both verbs boring