Learning & reference

Character encodings: UTF-8, UTF-16, the BOM and normalization

Encoding bugs almost never look like encoding bugs. They arrive as a CSV whose first column will not match, a name that is in the database twice, a search that finds nothing, or a length check that rejects a perfectly ordinary comment. Here is what is actually happening, and a file you can download to reproduce each one.

Why encoding bugs are so hard to spot

Three properties conspire to make them silent. Nothing throws, nothing logs, and the damage usually appears several systems away from the cause.

The wrong decoder rarely fails

Every byte from 00 to FF is a valid Latin-1 character. Decoding UTF-8 as Latin-1 therefore cannot error. It just produces wrong text, and keeps going.

Identical text can differ in bytes

café has two legitimate spellings in Unicode that render identically. == says they are different, and so does your unique index.

Length has four different answers

Bytes, code points, UTF-16 units and what a person would count as characters are four different numbers. Your validator and your user are rarely using the same one.

The encodings you will actually meet

Everything else is a rounding error in practice, but these six account for nearly every real-world bug.

EncodingBytes per characterSignatureWhat to know
UTF-81 to 4EF BB BF (optional) The default for the web, JSON, and almost every modern API. ASCII is unchanged, so an ASCII-only file is byte-identical to its UTF-8 version. Self-synchronising: a decoder can recover after a bad byte.
UTF-16 LE2 or 4FF FE The internal string format of Windows, Java, C# and JavaScript. Characters beyond U+FFFF take two units, called a surrogate pair. Doubles the size of English text.
UTF-16 BE2 or 4FE FF Same encoding, opposite byte order. Rare in files, common in network protocols. Without a BOM you cannot tell the two apart except by guessing.
ISO-8859-1 (Latin-1)1none 256 characters, no more. Cannot represent the euro sign, curly quotes or any non-Western script. Its real significance is that it never rejects input, which is what makes mojibake silent.
Windows-12521none Latin-1 with the unused 80-9F range filled in with the euro sign, curly quotes and dashes. Mislabelled as Latin-1 constantly. This is why a smart quote sometimes appears as a control character.
US-ASCII1none The first 128 characters, and a strict subset of UTF-8. Still the only safe assumption for protocol tokens, HTTP header names and email envelope addresses.

Practical rule: declare UTF-8 explicitly everywhere, including Content-Type: text/html; charset=utf-8, your database and connection collation, and the encoding= argument of every file open. A missing declaration is not neutral: something downstream will guess, and its guess depends on the machine's locale.

How mojibake happens, byte by byte

The word café in three encodings. Follow the bytes and the classic corruption becomes obvious.

Stored asBytesRead back as UTF-8Read back as Latin-1
UTF-863 61 66 C3 A9cafécafé
Latin-163 61 66 E9caf� (invalid)café
UTF-16 LE63 00 61 00 66 00 E9 00c a f é with nullssame, with nulls

Read it in reverse to diagnose. If you see é the source was UTF-8 read as Latin-1. If you see ’ the source was a UTF-8 curly apostrophe read the same way. If you see the source was not UTF-8 and the decoder correctly gave up. And if every second byte is a null, something wrote UTF-16 where UTF-8 was expected.

The byte-order mark

A BOM is the code point U+FEFF at the very start of a file. In UTF-16 it genuinely marks byte order. In UTF-8 there is no byte order to mark, so it is only a signature, and an unwelcome one in most places.

EncodingBOM bytesHelpsHurts
UTF-8EF BB BF Excel on Windows needs it to open a CSV as UTF-8 rather than the system code page. Shell scripts (the #! is no longer first), JSON parsers, PHP output before headers, concatenated files, and any CSV parser that does not strip it.
UTF-16 LEFF FE Effectively required. Without it a reader cannot know the byte order. Tools that assume any text file is UTF-8 read it as two junk characters.
UTF-16 BEFE FF Same.Same.

The failure to test for: a BOM in front of a CSV header makes the first column name id, not id. Every other column matches, so the import fails on exactly one field and the header looks perfect in every editor.

NFC and NFD: the same text, twice

Unicode lets an accented character be written either as one code point or as a base letter plus a combining mark. Both are correct, both render identically, and they are not equal.

FormCode pointsUTF-8 bytesWhere you meet it
NFC (composed)4 — c a f U+00E9 63 61 66 C3 A9 What most systems produce. The form the W3C recommends for the web.
NFD (decomposed)5 — c a f e U+0301 63 61 66 65 CC 81 macOS filenames. Drag a file from a Mac and its name arrives decomposed.

What this breaks: a user uploads café.pdf from a Mac and again from Windows and gets two files, because the names differ in bytes. A search for a composed name misses the decomposed rows. A unique constraint allows both. Fix: normalise to NFC at the boundary, once, on the way in. Do not normalise at comparison time only, or the duplicates are already stored.

"Length" is four different questions

Pick the wrong one and your 20-character limit rejects a two-emoji message, or your VARCHAR(255) truncates mid-character and corrupts the row.

TextUTF-8 bytesCode pointsUTF-16 unitsWhat a person counts
café (NFC)5444
café (NFD)6554
👍4121
👨‍👩‍👧‍👦257111

Which one does your language give you? JavaScript .length, Java String.length() and C# .Length all return UTF-16 code units. Python 3 len() returns code points. Go len() returns bytes. Rust .len() returns bytes and .chars().count() returns code points. None of them returns what the user sees, which needs grapheme-cluster segmentation (Intl.Segmenter in JavaScript).

Characters you cannot see

These are legal Unicode, survive a copy and paste, and are invisible in every editor that does not go looking for them.

Zero-width space & joiner

U+200B and U+200D have no width. They break exact-match search, defeat profanity filters, and make two visually identical usernames distinct. The joiner is also load-bearing inside emoji.

Right-to-left override

U+202E reverses display order. A file named invoice‮gnp.txt appears as invoicetxt.png. A classic way to disguise an executable as an image.

Non-breaking space

U+00A0 looks exactly like a space and is not one. trim() leaves it, split(' ') misses it, and a pasted value fails validation for no visible reason.

Homoglyphs

Cyrillic а (U+0430) renders identically to Latin a. The basis of lookalike domains and impersonated usernames. Only a codepoint-level check finds them.

Combining marks

Any number of accents can stack on one base character. Unbounded stacking is the "Zalgo text" effect, and can break layout or fixed-height rows.

Lone surrogates

Half of a surrogate pair, valid in JavaScript strings but not encodable as UTF-8. A common source of "invalid byte sequence" errors when JSON meets a database.

Test it with real files

Every case above has a file. Each one is generated from source, has a published SHA-256, and says on its own page exactly what it is built to catch.

What you want to proveFile
Your reader strips a UTF-8 BOMutf8-with-bom.txt against utf8-no-bom.txt
A BOM does not poison your first CSV columnbom-and-semicolons.csv
You detect UTF-16 and both byte ordersutf16le.txt and utf16be.txt
Latin-1 input does not become mojibakelatin1.txt
NFC and NFD names are not stored twicenfc-café.txt and nfd-café.txt
Invisible and RTL characters are handledzero-width-and-rtl.txt
Emoji in a filename survive the round trip🦊-emoji-name.txt
Non-Latin filenames surviveCyrillic and Japanese filename fixtures
Encoded-word headers decode (RFC 2047)unicode-headers.eml
Archive entry names are not garbledunicode-entry-names.zip
CRLF and LF are both handledline-endings-crlf.txt

Inspect a string yourself

Paste anything below to see the four lengths side by side, its normalization form, and any invisible characters it is carrying. Runs entirely in your browser; nothing is sent anywhere.

Keep going

Encoding test files QA test strings → Filename compatibility →