Why does "π".length return 2 in JavaScript but 1 in Python? The answer is a 1990s encoding decision that still causes truncation bugs, MySQL charset gotchas, and broken emoji today.
Open a JavaScript console and run "π".length. It comes back 2. Not 1 β the string has one visible character, but its .length says two. Run the same check in Python 3 (len("π")) and you get 1. Neither answer is wrong. They're measuring different things, and the reason traces back to a decision made in the early 1990s that most working programmers never have to think about β until a truncated emoji corrupts a database column, a substring() call splits a surname in half, or a "sanitize user input" regex silently mangles valid text.
This is the UTF-8 vs. UTF-16 story, and the surrogate pairs that make it leak into your code.
Before Unicode, text encoding was a swamp of incompatible single-byte code pages. ASCII covered 128 characters β enough for English, not enough for a Γ©, a Γ±, or anything outside the Latin alphabet. Windows-1252, ISO 8859-1, Shift-JIS, Big5, and dozens of other encodings each carved out their own 256-character space, meaning the same byte value could mean a different character depending on which code page you assumed. Interchange between systems written for different regions was routinely broken.
Unicode's fix was to stop tying "which character" to "which byte." It defines a single abstract space of code points β integers from U+0000 to U+10FFFF, about 1.1 million slots β where every character in every supported script gets exactly one number, permanently. U+0041 is A. U+00E9 is Γ©. U+1F600 is π. That mapping is encoding-independent. The open question Unicode itself doesn't answer is: how do you turn a code point number into bytes on disk or on the wire? That's what UTF-8, UTF-16, and UTF-32 each answer differently.
UTF-32 is the boring, obvious answer: every code point gets a fixed 4 bytes. Indexing is trivial (char[i] is a real O(1) operation), but ASCII text β the overwhelming majority of bytes moved on the internet β balloons to 4x its size. It's used internally by some libraries for processing but essentially never for storage or transmission.
UTF-16 was Unicode's original bet, baked into Windows, Java, JavaScript, and .NET during the era when Unicode only defined 65,536 code points (U+0000βU+FFFF, the Basic Multilingual Plane). At 2 bytes per code point, it seemed like a reasonable middle ground: better than UTF-32's waste, simpler than a variable-width scheme.
Then Unicode grew past 65,536 code points to accommodate historic scripts, mathematical symbols, and β much later β emoji, which live up around U+1F300βU+1FAFF. A fixed 2 bytes can't address 1.1 million values. UTF-16's patch for this is the surrogate pair: two carve-outs in the BMP, U+D800βU+DBFF (high surrogates) and U+DC00βU+DFFF (low surrogates), that are reserved and never assigned as real characters. Any code point above U+FFFF gets encoded as one high surrogate followed by one low surrogate β two 16-bit units standing in for a single character.
That's why "π".length is 2 in JavaScript: π is U+1F600, above the BMP, so it's stored as the surrogate pair π, and .length counts UTF-16 code units, not characters. Split that string at index 1 with .slice(0, 1) and you get half a surrogate pair β an unpaired surrogate, which is invalid UTF-16 and renders as a broken-character glyph (οΏ½) or throws, depending on what consumes it downstream.
This isn't a JavaScript quirk specifically β it's the visible symptom of choosing UTF-16 as an internal string representation. Java's char is a UTF-16 code unit for the same historical reason (Java predates Unicode's expansion past the BMP), which is why String.length() has the identical surprise. C#/.NET strings have the same property.
UTF-8 takes a different approach: 1 to 4 bytes per code point, with the byte count determined by the value being encoded.
| Code point range | Bytes | Byte pattern |
|---|---|---|
U+0000βU+007F (ASCII) | 1 | 0xxxxxxx |
U+0080βU+07FF | 2 | 110xxxxx 10xxxxxx |
U+0800βU+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000βU+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
Two properties made this the encoding that ate the web. First, it's backward-compatible with ASCII at the byte level: any valid ASCII file is already valid UTF-8, byte-for-byte identical. Every C string function that stops at a null byte, every log parser, every legacy tool built assuming 7-bit ASCII kept working without modification. Second, it's self-synchronizing: every continuation byte starts with the bits 10, so if you land anywhere in the middle of a multi-byte sequence β a truncated network read, a grep seeking into a file β you can tell immediately whether you're at a character boundary and walk backward or forward to find one. UTF-16 doesn't have this property nearly as cleanly, and UTF-32, despite being fixed-width, still has byte-order (endianness) ambiguity that UTF-8 sidesteps entirely.
The cost is that UTF-8 is variable-width the same way UTF-16 secretly is β a single "character" can be 1 to 4 bytes, and for code points above U+FFFF (emoji, rare CJK ideographs, mathematical alphanumeric symbols), that's 4 bytes, encoding the exact same code point that UTF-16 needs a surrogate pair for. Neither encoding gives you O(1) indexing by character once you're outside pure ASCII or BMP text respectively β the difference is which range breaks first and how visibly.
By the mid-2010s, W3Techs surveys consistently showed UTF-8 used by well over 95% of websites, and it's the mandated encoding for JSON (per RFC 8259) and the de facto default for nearly every modern text format, API payload, and source file. HTML5 requires UTF-8 for new documents. Rust and Go strings are UTF-8 by definition β there's no separate "wide" representation to fall back to.
"π" as UTF-8: F0 9F 98 80 (4 bytes, one sequence)
"π" as UTF-16: D8 3D DE 00 (2 code units: high surrogate + low surrogate)
"π" as UTF-32: 00 01 F6 00 (4 bytes, one code point, no surrogates)
String length and truncation. "Truncate to 280 characters" is ambiguous unless you specify code points, grapheme clusters, or code units. Truncating a UTF-16 string mid-surrogate-pair, or a UTF-8 byte sequence mid-continuation-byte, produces invalid text that downstream parsers may reject or silently corrupt. JavaScript's Array.from(str).length counts code points (correctly stepping over surrogate pairs); str.length does not.
Emoji aren't even the hard case. A single visible glyph β say, a family emoji (π¨βπ©βπ§βπ¦) or πΊπΈ β can be multiple code points joined by zero-width joiners or regional indicator pairs. That's a grapheme cluster, a layer above code points entirely. Code-point-aware slicing (Array.from) still breaks these apart; you need a grapheme-segmentation library (or Intl.Segmenter in modern JS) to truncate text the way a human actually reads it.
Database columns. MySQL's historic utf8 charset is not actually full UTF-8 β it's limited to 3 bytes per character, meaning it silently can't store anything above U+FFFF, including most emoji. The fix is utf8mb4, which is genuinely full UTF-8. This has bitten enough production systems that "did you mean utf8mb4?" is a standing joke in MySQL circles.
Regex and validation. A regex character class like [a-zA-Z] operates on code units in most engines by default, meaning \uD83D alone matches nothing sensible and can split a surrogate pair if your validation logic isn't surrogate-aware. Engines that support the Unicode flag (/u in JavaScript) treat the pattern and subject as code points instead, which changes how . and character classes match astral characters.
Byte order marks. UTF-16 files need a BOM (FE FF or FF FE) to signal endianness, since a 16-bit unit can be stored either way. UTF-8 has no endianness ambiguity β a UTF-8 BOM (EF BB BF) is legal but purely a signal, not a structural requirement, and most modern tooling recommends omitting it.
Pick UTF-8 for storage, transmission, and file formats β it's the ecosystem default for a reason, and fighting it (MySQL's utf8, forcing UTF-16 disk formats) mostly just reintroduces bugs everyone else already fixed. But know that your programming language's in-memory string type might still be UTF-16 (JavaScript, Java, C#) or UTF-32-ish (Python 3's flexible string representation), which is orthogonal to your storage encoding and is exactly where surrogate-pair bugs are born. When you need to reason about "how many characters is this really," decide explicitly whether you mean code units, code points, or grapheme clusters β they're three different numbers, and .length only ever gives you one of them. If you want to see the code-point-to-byte mapping directly instead of trusting a library, Utilix's Unicode Inspector breaks a string down character by character into its code points and UTF-8/UTF-16 byte representations.