Character sets: ASCII, Latin-1 and Windows-1252
ASCII as a 7-bit standard, how the high 128 positions became a collision zone between Latin-1 and Windows-1252, and why legacy text breaks when it moves between systems.
ASCII is only 7 bits
ASCII defines 128 code positions: 0-31 are control codes, 32-126 are printable, and 127 is delete. Anything above 127 is outside the standard, which is exactly where the trouble starts.
| Range | Contents | Examples |
|---|---|---|
| 0x00-0x1F | Control codes | Tab 09, LF 0A, CR 0D, ESC 1B |
| 0x20-0x3F | Punctuation and digits | Space, !, 0-9 |
| 0x40-0x5F | Uppercase and symbols | A-Z, [, backslash, ] |
| 0x60-0x7E | Lowercase and symbols | a-z, {, |, }, ~ |
| 0x7F | Delete | One code point |
| 0x80-0xFF | Not ASCII | Interpretation depends entirely on the encoding |
b"A".decode("ascii") # 'A'
chr(65) # 'A'
ord("A") # 65
b"\x80".decode("ascii") # UnicodeDecodeError — outside the 7-bit range
b"\x80".decode("latin-1") # '\x80' — Latin-1 maps every byte to a code pointThe high half became a collision zone
Latin-1 (ISO-8859-1) fills 0x80-0xFF with Western European characters. Microsoft's Windows-1252 filled the same range with different glyphs, most famously putting curly quotes and the euro sign in positions Latin-1 leaves undefined.
| Byte | Latin-1 | Windows-1252 | Consequence |
|---|---|---|---|
| 0x80 | Undefined control | Euro sign | A euro in 1252 becomes a control character in Latin-1 |
| 0x91 | Undefined control | Left single quote | Curly quote renders as a box or a control code |
| 0x92 | Undefined control | Right single quote | The classic apostrophe corruption |
| 0x93/0x94 | Undefined control | Left/right double quote | Quotes turn into garbled pairs |
| 0xE9 | e acute | e acute | The two agree here, which hides the problem |
| 0xF7 | Division sign | Division sign | Agreement again |
# the same bytes, three different stories
raw = b"caf\xe9 \x92quoted\x92 \x80 10"
raw.decode("latin-1") # 'café quoted 10'
raw.decode("cp1252") # 'café ’quoted’ € 10' — the intended text
raw.decode("utf-8", "replace") # 'caf��quoted...' — bytes are not valid UTF-8Both encodings agree for most accented letters, so a file can look correct in the middle and wrong only where the bytes differ — which is why these bugs survive casual inspection.
Living with legacy text
- Detect before decoding: try UTF-8 first, and fall back to a legacy page only when it fails.
- Never decode as Latin-1 by default for user input — it never raises, so corruption is silent.
- Convert once at the boundary, and store UTF-8 internally.
- Record the source encoding alongside the data if you cannot convert immediately.
- The Windows-1252 range 0x80-0x9F is the highest-risk zone; check it explicitly in tests.
def decode_legacy(raw: bytes) -> str:
for enc in ("utf-8", "cp1252", "latin-1"):
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
raise ValueError("no candidate encoding matched")errors="replace" argument hides data loss behind a placeholder. Use it for display, never for storage — a decoded value that contains U+FFFD should be rejected or quarantined, not written back to the database.FAQ
Is Latin-1 the same as ISO-8859-1?
latin-1 is treated as ISO-8859-1, but browsers map it to Windows-1252 for HTML, which is a documented historical inconsistency.Why does my file open fine in one editor and break in another?
Related
Bytes, bits, hex and number bases Mojibake: diagnosing and fixing broken text
Last refreshed 2026-09-18.