Skip to content

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