UTF-16, UTF-32, BOM and endianness

Code units versus code points, surrogate pairs, what a byte order mark really is, why Windows and Java default to UTF-16 internally, and when a BOM helps or hurts.

Code units are not code points

UTF-16 stores most characters in one 16-bit code unit. Characters above U+FFFF need two code units, a surrogate pair, which is why string length in UTF-16 is not the number of characters.

const s = "A\u{1F600}";
s.length;                   // 3 — UTF-16 code units, not code points
[...s].length;              // 2 — code points, thanks to the iterator
s.charAt(1);                // a lone high surrogate — a broken half

// correct iteration
for (const ch of s) console.log(ch.codePointAt(0).toString(16));
// 41
// 1f600
CharacterCode pointUTF-8 bytesUTF-16 units
AU+004111
e acuteU+00E921
Euro signU+20AC31
Grinning faceU+1F60042 (surrogate pair)
Rare ideographU+2000042

The byte order mark

UTF-8        EF BB BF              (a marker, not needed for order)
UTF-16 BE    FE FF                 (big endian)
UTF-16 LE    FF FE                 (little endian)
UTF-32 BE    00 00 FE FF
UTF-32 LE    FF FE 00 00
  • In UTF-16 the BOM is a real byte-order indicator, because the file could legitimately be either.
  • In UTF-8 there is only one byte order, so the BOM carries no information — it is only a signature.
  • Some tools treat a UTF-8 BOM as part of the first line, which is why the first CSV header can come back with an invisible prefix.
  • The Unicode consortium recommends against a UTF-8 BOM, but Windows tooling and Excel emit one anyway.
open("f.csv", encoding="utf-8")       # leaves the BOM in the first field
open("f.csv", encoding="utf-8-sig")   # strips it if present, harmless if absent

# check for a BOM before trusting the first byte
raw = open("f.csv", "rb").read(3)
print(raw == b"\xef\xbb\xbf")        # True when a UTF-8 BOM is present

Where each encoding lives

PlatformInternal stringFile defaultNote
JavaScript / DOMUTF-16UTF-8 over the wirelength counts code units
JavaUTF-16Platform default, historicallyUse explicit charsets on every stream
Windows APIsUTF-16 (wchar_t is 16-bit)UTF-16 LE with BOMLegacy code pages elsewhere
Linux / macOSwchar_t is 32-bitUTF-8The BOM is unusual
Python 3Code pointsUTF-8 sourceIndexing is by code point
Go / RustUTF-8 bytesUTF-8Indexing is by byte; iterate runes or chars
⚠️
Slicing a UTF-16 or UTF-8 string by index can split a surrogate pair or a multi-byte sequence, producing invalid text. Always slice on code point boundaries or use a library that understands grapheme clusters, and remember that one visible character can be several code points.

FAQ

Should I write a UTF-8 BOM?
Generally no. It breaks naive parsers and adds nothing, since there is only one UTF-8 byte order. Accept and strip it on read for interoperability with tools that emit one.
Why is my string length wrong for emoji?
You are counting UTF-16 code units or UTF-8 bytes. Count code points, or better, count grapheme clusters if the number must match what a user perceives.

Unicode in depth: planes, properties and normalisation Bytes, bits, hex and number bases

Last refreshed 2026-09-18.