Module 1 12 min

Strings and Bytes

How text is stored, encoded, and moved around.

Last lesson ended on a cliffhanger: a code point is an abstract number, not what actually sits in memory. This lesson closes the loop — how strings become bytes (encoding), how bytes become strings again (decoding), why UTF-8 won the encoding wars, and what happens when this handshake goes wrong (hello, mojibake).

Why does this exist?

Unicode gives 🎉 the number 127881 — but memory, disks, and networks only move bytes, and one byte maxes out at 255. So we need a packing scheme that fits big code points into sequences of small bytes, without wasting space on the common case of plain English. That scheme is an encoding, and UTF-8 is the one that won: it now carries the overwhelming majority of the web.

The problem

Here's the naive solution: since the biggest code points need about 21 bits, just use 4 bytes for every character. This exists (it's called UTF-32) and it works — but it makes every English document four times larger than ASCII, since a plain letter like A wastes three zero bytes. The internet was not going to accept 4x bandwidth for nothing.

UTF-8's insight: use a variable number of bytes. Common characters get short encodings; rare ones get longer ones.

UTF-8: the variable-width trick

  1. 1 byte: ASCII (U+0000 to U+007F)Any code point up to 127 is stored as a single byte, identical to ASCII. Every ASCII file ever written is already valid UTF-8 — this backwards compatibility is why UTF-8 won.
  2. 2 bytes: most alphabets (up to U+07FF)
  3. 3 bytes: most of the rest (up to U+FFFF)
  4. 4 bytes: the outer planes (up to U+10FFFF)

The byte patterns are self-describing: the first byte's leading bits announce how many bytes the character uses, and continuation bytes have a distinctive signature. A decoder dropped into the middle of a stream can always find the next character boundary — a genuinely elegant design.

Encode and decode

Encoding turns a string into bytes; decoding turns bytes back into a string. Both require naming the scheme — and both sides must name the same scheme.

s = "café 🎉"

data = s.encode("utf-8")
print(data)
# b'caf\xc3\xa9 \xf0\x9f\x8e\x89'

print(len(s))      # 6  characters (code points)
print(len(data))   # 10 bytes: c,a,f = 3, é = 2, space = 1, 🎉 = 4

print(data.decode("utf-8"))   # 'café 🎉'  — round trip complete

The same in JavaScript:

const bytes = new TextEncoder().encode("café 🎉");
console.log(bytes.length);                    // 10
console.log(new TextDecoder().decode(bytes)); // 'café 🎉'

Characters and bytes are different units

Notice len gave 6 and 10 for the same text. Neither is wrong — they measure different things. This bites engineers constantly: a database column limited to 100 bytes holds fewer than 100 characters of Hindi; a form validating 280 characters may produce over 1,000 bytes; and JavaScript adds a third unit, reporting a length of 7 for this string because it counts 16-bit code units, which splits 🎉 in two. Always know which unit an API means.

Mojibake: when the handshake fails

Encode with one scheme, decode with another, and you get mojibake (from Japanese: "character transformation") — the classic garbled text:

data = "café".encode("utf-8")     # é becomes bytes 195, 169
print(data.decode("latin-1"))     # 'café'   — oops

What happened: in UTF-8, é is the two-byte sequence 195, 169. A Latin-1 decoder doesn't know about multi-byte sequences — it maps each byte to its own character: 195 is à and 169 is ©. Whenever you've seen ’ where an apostrophe should be, or é in an email, you've witnessed exactly this bug. The data isn't corrupted — it's being read with the wrong table, and re-decoding with the right one recovers it.

The practical rule

Use UTF-8 everywhere: files, databases, APIs, HTTP headers. Mojibake only occurs at boundaries where two systems disagree, and the industry has converged on UTF-8 precisely to make disagreement rare. When you do see garbage text, the fix is almost never the data — it's finding which side is decoding with the wrong scheme.

Why AI engineers care

This is the exact floor that LLMs stand on:

  • Tokenizers eat bytes. Modern tokenizers (like the byte-pair encoding used by GPT-style models) start from the UTF-8 bytes of your text. This is why an emoji can cost multiple tokens while the word "the" costs one, and why non-English text is often more expensive per sentence — more bytes in, more tokens out.
  • Context windows and bills are downstream of bytes. Understanding characters vs bytes vs tokens is the difference between guessing and knowing when you budget a context window.
  • APIs move bytes. Every request you'll send to a model provider is UTF-8 encoded JSON over the wire. When something garbles, you now know exactly where to look.

Build it yourself

Write a byte inspector, then deliberately cause and repair mojibake:

def inspect(s):
    data = s.encode("utf-8")
    print(f"text: {s!r}")
    print(f"code points: {len(s)}, bytes: {len(data)}")
    for ch in s:
        b = ch.encode("utf-8")
        print(f"  {ch!r}  U+{ord(ch):04X}  ->  {list(b)}  ({len(b)} bytes)")

inspect("Go 中 🎉")

Then the repair drill: take garbled = "café".encode("utf-8").decode("latin-1") — print it to admire the damage — and recover the original with garbled.encode("latin-1").decode("utf-8"). Once you've un-mojibaked a string on purpose, you'll never be mystified by é in the wild again.

Summary

  • An encoding is the packing scheme between abstract code points and physical bytes; UTF-8 is the universal standard.
  • UTF-8 is variable-width: 1 byte for ASCII, up to 4 for emoji — efficient and perfectly backwards compatible.
  • Characters, bytes, and (in JavaScript) code units are three different length measurements for the same string; know which one your API means.
  • Mojibake = encoded with one scheme, decoded with another; the data is recoverable, the fix is agreeing on UTF-8.
  • LLM tokenizers sit directly on UTF-8 bytes — this lesson is literally the input layer of every model you'll use. Next stop: tokenization.