Why len() disagrees with your database, your front end and your user
Because "how long is this string" has four correct answers, and your language, your database, your front end and your user are not all giving the same one. On a corpus of 300 strings built to contain the awkward cases, the byte count differs from the codepoint count on 294 of them, and the UTF-16 count differs on 60. All four numbers agree on 6.
The four lengths
A piece of text can be counted as grapheme clusters (what a person would call characters - one of these can be several codepoints), as codepoints (Unicode scalar values), as UTF-16 code units (where anything above U+FFFF costs two), or as UTF-8 bytes (one to four per codepoint). For plain ASCII all four are the same number, which is why this never shows up in testing and always shows up in production.
An accented letter written as a base letter plus a combining mark is one grapheme, two
codepoints, two UTF-16 units and three bytes. An emoji with a skin tone and a joiner is one
grapheme and can be seven codepoints and twenty bytes: 3 cases in this corpus tie for the worst ratio at
20 bytes over 7 codepoints, the first of them
u300-0033.
Which layer counts which unit
| where | what it counts |
|---|---|
Python len(s) | codepoints |
JavaScript s.length, Java String.length(), C# .Length | UTF-16 code units |
Go len(s), Rust String::len() | UTF-8 bytes |
PostgreSQL char_length() | codepoints |
PostgreSQL octet_length(), index size limits | bytes |
MySQL VARCHAR(n) on utf8mb4 | characters to declare, bytes for row and index limits |
JSON Schema maxLength | codepoints |
| HTTP headers, URLs, log lines | bytes |
| a text field a person is filling in | grapheme clusters |
Read that table as a list of places where two limits that look identical are not. A form that allows twenty characters in the browser is counting UTF-16 units; the Python service behind it is counting codepoints; the column it lands in may be counting bytes. Three numbers, one field.
What that costs in practice
- Client and server disagree. The front end says the input fits, the API rejects it, and the user has no way to see the difference because the two characters that caused it are invisible.
- Silent truncation at the storage layer. A byte-counted column takes the first n bytes of a value the application measured in codepoints, and what comes back is either short or no longer valid text at all.
- Wrong-looking analytics. Average message length, "characters typed", and any per-character billing quietly measure a different thing in each service.
- Index and key limits. A unique index that fits in a byte budget for Latin text fails on the first name written in another script.
What to do instead
- Write the unit into the limit. "max 280" is not a specification. "max 280 codepoints, NFC-normalised" is one that two teams can implement identically.
- Validate in the unit the storage enforces. If the column is bytes, check bytes
-
len(s.encode("utf-8"))- before the insert, not after the exception. - Count graphemes only where a person is looking. In JavaScript that is
Intl.Segmenter; Python's standard library has no grapheme segmentation at all, so a correct count there needs a third-party module. Do not approximate it with a regular expression over codepoints. - Test with strings that break the assumption rather than with a longer version of the same ASCII. Of the 300 strings measured here, 64 carry combining marks, 60 live above the BMP, 60 contain invisible zero-width codepoints and 34 carry a variation selector. Every one of those makes at least two of the four counts disagree.
None of this is exotic input. It is a name with an accent, a message with an emoji, and text pasted out of a word processor.
Where these numbers come from
They are computed with Python's unicodedata on UCD 15.0.0 over UNICODE-300,
a corpus of 300 labelled strings built to break naive text handling - 10 categories,
30 cases each, every case carrying its codepoint, UTF-8 and UTF-16 lengths, its
normalisation flags and a one-line description of the trap. Twenty cases and the checker are free
and public domain; the full corpus is a paid download. The corpus and the free
sample are here.
Where these 300 strings come from
Every number on this page was computed from UNICODE-300: 300 labelled strings, each property measured with unicodedata rather than asserted. The full corpus is EUR 19, one-time; a 20-case slice and the checker are free on the product page, public domain, no account.