String Length in 8 Languages: Emoji, Graphemes, Bytes
What length() returns for cafรฉ, ๐๐ฝ, ๐จโ๐ฉโ๐ง and ๐ฏ๐ต in Python, JS, Swift, Dart, Java, C#, PHP and SQLite, measured, plus truncation bugs and DB column limits.
Series: Text & Encoding lecture 5/6 (advanced). For developers who validate, truncate or store user text and have been surprised that a "10-character limit" means different things in different layers. After this lecture you will know which of four "lengths" each API returns, how to cut a string without breaking an emoji, and which count to use for a UI, a database and a protocol.
This is for you if a username like tomรกs๐จโ๐ป passed the form check in the browser and failed in the API, if a push notification preview ended in a ?, or if a "Read moreโฆ" cut turned a family emoji into a couple. The short answer: there is no single string length. There are UTF-8 bytes, UTF-16 code units, code points and grapheme clusters, and every language picks one as its default. Below, the same 14 strings go through Python, JavaScript, Swift, Dart, Java, C#, PHP and SQLite, with every number taken from a real run.
Setup for every output on this page: Apple Silicon Mac (M1 Pro), macOS 26.6, Python 3.9.6 (Unicode 13.0 database), JavaScriptCore jsc from macOS, Swift 6.4.2, Dart 3.11.5, OpenJDK 26, .NET 10.0.403 (C#), PHP 8.5.6 with intl (ICU 78.3) and SQLite 3.51.0. Node.js, browsers, MySQL and Windows were not run.
The four "lengths" of one string
Lecture 1 separated characters, code points and bytes. To compare languages we need one more layer, because two languages store text in UTF-16:
- UTF-8 bytes: what goes on disk and over the network. PHP
strlen, Swiftutf8.count, SQLiteoctet_length(). - UTF-16 code units: 16-bit units. Characters above U+FFFF (most emoji, rare CJK) need two of them, called a surrogate pair. JavaScript
.length, Javalength(), C#Length, Dart.length. - Code points: the numbered Unicode characters (
U+00E9). Pythonlen(), PHPmb_strlen, SQLitelength(). - Grapheme clusters: what a person calls "one character". A flag is two code points, ๐๐ฝ is a thumb plus a skin tone modifier, and ๐จโ๐ฉโ๐ง is three people glued by two invisible ZERO WIDTH JOINER (U+200D) characters. Unicode's rule for this is called an extended grapheme cluster (UAX #29).
The big table: 14 strings, measured
Every string was written with escapes ("eฬ", "\u{1F468}โโฆ") in each language so that no editor could normalize it. Within each unit, every API agreed exactly; the next table lists which API fed which column.
| String (code points) | UTF-8 bytes | UTF-16 units | Code points | Graphemes |
|---|---|---|---|---|
| รฉ NFC (U+00E9) | 2 | 1 | 1 | 1 |
| รฉ NFD (U+0065 U+0301) | 3 | 2 | 2 | 1 |
| ํ NFC (U+D55C) | 3 | 1 | 1 | 1 |
| ํ NFD (3 jamo) | 9 | 3 | 3 | 1 |
| ๐ | 4 | 2 | 1 | 1 |
| ๐๐ฝ (thumb + skin tone) | 8 | 4 | 2 | 1 |
| ๐จโ๐ฉโ๐ง (3 people + 2 ZWJ) | 18 | 8 | 5 | 1 |
| ๐ฏ๐ต (2 regional indicators) | 8 | 4 | 2 | 1 |
| ๐ ฎท (U+20BB7) | 4 | 2 | 1 | 1 |
| ใฌ NFD (ใซ + U+3099) | 6 | 2 | 2 | 1 |
| aรฑo | 4 | 3 | 3 | 3 |
| ๆๅญๅใ | 12 | 4 | 4 | 4 |
| ํ๊ธ | 6 | 2 | 2 | 2 |
| cafรฉ | 5 | 4 | 4 | 4 |
And the strings English-speaking users actually type into sign-up forms:
| String | UTF-8 bytes | UTF-16 units | Code points | Graphemes |
|---|---|---|---|---|
| cafรฉ (NFD, pasted from a PDF) | 6 | 5 | 5 | 4 |
mia_๐ฒ๐ฝ๐ฎ |
16 | 10 | 7 | 6 |
tomรกs๐จโ๐ป |
17 | 10 | 8 | 6 |
So tomรกs๐จโ๐ป is 6 characters to the user, 8 to Python, 10 to JavaScript and 17 to the database row. A "max 8 characters" rule written once in JS and once in Python will accept it in one place and reject it in the other.
Which API returns which unit
| Language | UTF-8 bytes | UTF-16 units | Code points | Graphemes |
|---|---|---|---|---|
| Python 3.9 | len(s.encode()) |
len(s.encode('utf-16-le'))//2 |
len(s) |
none in stdlib |
| JavaScript (jsc) | no TextEncoder in jsc |
s.length |
[...s].length |
Intl.Segmenter |
| Swift | s.utf8.count |
s.utf16.count |
s.unicodeScalars.count |
s.count |
| Dart | (utf8.encode) |
s.length |
s.runes.length |
none in core |
| Java 26 | getBytes(UTF_8).length |
s.length() |
codePointCount |
BreakIterator, regex \X |
| C# (.NET 10) | Encoding.UTF8.GetByteCount |
s.Length |
EnumerateRunes().Count() |
StringInfo.LengthInTextElements |
| PHP 8.5 | strlen |
โ | mb_strlen |
grapheme_strlen |
| SQLite 3.51 | octet_length() |
โ | length() |
none |
Things the table hides that we checked directly:
Intl.Segmenterexists in this jsc (typeof Intl.Segmenterprintedfunction), butTextEncoderdoes not (undefined), so byte counts in jsc need a hand-written loop.- Python 3.9 has no grapheme support:
re.compile(r'\X')fails withre.error: bad escape \X at position 0. The third-partyregexmodule supports\X, but nothing was installed for this lecture. - Swift compares by grapheme too:
"\u{00E9}" == "e\u{0301}"printedtruein Swift,falsein Dart andFalsein C# with==. C#'sstring.Compare(..., StringComparison.InvariantCulture)returned0(equal). - PHP's
mb_strwidthis a fourth opinion: it gave 2 forํand 4 forๆๅญๅใ, because East Asian wide characters count as two columns. It is meant for terminal alignment, not limits.
Truncation bugs, with real outputs
Counting wrong is annoying; cutting wrong corrupts data. Here is what each language does when you cut by its default unit.
JavaScript: a lone surrogate. "OK๐".slice(0, 3) keeps only the first half of the surrogate pair:
"OK\ud83d" 3 false OK๏ฟฝ
URIError: String contained an illegal UTF-16 sequence.
The values are JSON.stringify(cut), cut.length, cut.isWellFormed() and cut.toWellFormed(). The broken string is still a valid JS string, so nothing fails until it leaves the program: encodeURIComponent threw the URIError above. That is the "search query works for everyone except the user with the emoji" bug.
JavaScript again: a family loses a child. Cutting the family emoji at 5 code units gives "๐จโ๐ฉ": a valid string, no error, different meaning. Code point slicing ([...s].slice(0, 3)) produces the same two people. Only grapheme slicing is safe:
const seg = new Intl.Segmenter("en", {granularity: "grapheme"});
function truncate(str, n) {
let out = "", i = 0;
for (const {segment} of seg.segment(str)) {
if (i++ === n) return out + "โฆ";
out += segment;
}
return out;
}
print(truncate("Hola \u{1F1F2}\u{1F1FD}!", 6)); // Hola ๐ฒ๐ฝโฆ
Java and C#: the half emoji becomes ? or ๏ฟฝ on the way out. Java's "OK๐".substring(0, 3) encoded to UTF-8 as 4f 4b 3f, so the database receives a literal question mark. C#'s Substring(0, 3) encoded as 4F4BEFBFBD, which is U+FFFD (๏ฟฝ). With a strict encoder (new UTF8Encoding(false, true)) .NET refuses instead:
EncoderFallbackException: Unable to translate Unicode character \\uD83D at index 2 to specified code page.
The fix in Java is to cut at a code point boundary (s.offsetByCodePoints(0, n)) or, better, a BreakIterator boundary; in C#, new StringInfo(s).SubstringByTextElements(0, 3) returned the whole ๊ฐ์กฑ๐จโ๐ฉโ๐ง.
Python and PHP: cutting bytes. Byte limits are where this really bites, because protocols and columns measure bytes. In Python:
>>> "ๆๅญๅใ".encode()[:10].decode()
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe3 in position 9: unexpected end of data
errors="ignore" drops the partial character and gives ๆๅญๅ, which looks like the fix. It is not: "OK๐๐ฝ" cut to 7 bytes became OK๐. The skin tone modifier vanished silently, and the user's emoji changed. In PHP, substr("ํ๊ธ ๊นจ์ง", 0, 4) returned the bytes ed959cea, and json_encode of that returned false with Malformed UTF-8 characters, possibly incorrectly encoded, which is how a whole API response becomes empty. mb_substr cuts by code point, so mb_substr("๊ฐ์กฑ๐จโ๐ฉโ๐ง", 0, 4) ended with a dangling ZWJ (โฆf09f91a8e2808d).
PHP's intl extension has the right tool: grapheme_extract with GRAPHEME_EXTR_MAXBYTES returns the longest run of whole graphemes that fits in N bytes:
$s = "๊ฐ์กฑ๐จโ๐ฉโ๐ง!"; // 6 + 18 + 1 = 25 bytes
foreach ([23, 24] as $max) {
$next = 0;
var_dump(grapheme_extract($s, $max, GRAPHEME_EXTR_MAXBYTES, 0, $next), $next);
}
// string(6) "๊ฐ์กฑ" int(6) (the family needs 24 bytes)
// string(24) "๊ฐ์กฑ๐จโ๐ฉโ๐ง" int(24)
Common mistake: one "max length" checked in three layers
The mistake. The product rule says "display names up to 10 characters". The web form checks value.length <= 10 (UTF-16), the API checks len(name) <= 10 (code points), and the column is VARCHAR(10).
What actually happens. tomรกs๐จโ๐ป is 10 in JS, so the form accepts it; it is 8 in Python, so the API accepts it too; then a user types five flags ๐ฒ๐ฝ๐ฏ๐ต๐ฐ๐ท๐ช๐ธ๐บ๐ธ, which is 5 graphemes, 10 code points and 20 UTF-16 units. The form says "too long" for something that looks like five characters. Meanwhile, a name that the API cuts with name[:10] can end in the middle of a ZWJ sequence.
Fix. Decide per layer, on purpose:
| What you are limiting | Count | Why |
|---|---|---|
| What the user sees ("10 characters") | graphemes | matches the screen; never splits an emoji |
| A byte budget (column bytes, a header, a push payload) | UTF-8 bytes, cut on grapheme boundaries | the limit is physical |
VARCHAR(n) in MySQL, length() in SQLite |
code points | that is what the database counts |
| String indexes, cursor positions in JS, Java, C# | UTF-16 units | that is what the API indexes by |
Then enforce the strictest one on the server and show the user-facing one in the UI. If you build Flutter apps: Flutter's documentation says TextField.maxLength counts grapheme clusters, while Android's classic maxLength filter counts UTF-16 chars (both described from documentation, not run here).
Database columns: SQLite measured, MySQL described
SQLite treats the declared length as decoration:
sqlite> create table u(name varchar(5));
sqlite> insert into u values ('abcdefghijklmnop');
sqlite> select name, length(name), octet_length(name) from u;
abcdefghijklmnop|16|16
๐จโ๐ฉโ๐ง|5|18
If you need a limit, write it yourself, and note that length() counts code points: with check (length(name) <= 4), ๆๅญๅใ was accepted and the family emoji was rejected with CHECK constraint failed: length(name) <= 4 (19), although it is one character on screen. substr(fam, 1, 2) returned the bytes F09F91A8E2808D: a man plus a dangling joiner. SQLite also does not normalize: char(0xe9) = 'e' || char(0x301) returned 0, and the built-in upper('aรฑo') returned AรฑO because it only folds ASCII.
MySQL (not run here; from the MySQL reference manual): VARCHAR(n) counts characters (code points), not bytes. The charset called utf8 is an alias of utf8mb3, which stores at most 3 bytes per character, so any 4-byte character (every emoji, and ๐ ฎท) is rejected in strict mode with error 1366 "Incorrect string value". Use utf8mb4, which is the default since MySQL 8.0. Byte limits still exist below the character limit: a row is limited to 65,535 bytes, and index key prefixes have a byte limit (767 or 3,072 bytes depending on the row format), so VARCHAR(255) in utf8mb4 can need up to 1,020 bytes in an index. The SQL basics lecture covers the queries themselves.
Measurement: what counting graphemes costs
Grapheme counting is not free: it must walk the string and apply the UAX #29 rules. We repeated "ๆๅญๅใcafรฉํ๊ธ๐๐ฝ " 100,000 times (1,200,000 graphemes, 1,300,000 code points, 1,500,000 UTF-16 units, 3,200,000 bytes) and took the median of 5 runs:
| Language | Call | Result | Median time |
|---|---|---|---|
| jsc | s.length |
1,500,000 | 0.0 ms |
| jsc | [...s].length |
1,300,000 | 22.1 ms |
| jsc | Intl.Segmenter loop |
1,200,000 | 84.2 ms |
| PHP 8.5 | strlen |
3,200,000 | 0.00 ms |
| PHP 8.5 | mb_strlen |
1,300,000 | 2.99 ms |
| PHP 8.5 | grapheme_strlen |
1,200,000 | 25.20 ms |
Swift -O |
s.unicodeScalars.count |
1,300,000 | 5.37 ms |
Swift -O |
s.count |
1,200,000 | 17.69 ms |
| Python 3.9 | len(s.encode()) |
3,200,000 | 1.61 ms |
Two lessons. Stored lengths (.length, strlen, Python len) are O(1). Grapheme counts are O(n): about 70 ns per grapheme in jsc's Intl.Segmenter, 21 ns in PHP's ICU-based grapheme_strlen. That is nothing for a 30-character name and real money in a loop over a 10 MB log. Count graphemes at the edges (validation, truncation for display), not in hot loops.
Honest limits / version notes
Intl.Segmenteris in ECMA-402 (the 2022 edition); we only checked that this macOS jsc has it.isWellFormed/toWellFormedare ES2024 and also exist here.- Java's
BreakIteratorfollows extended grapheme clusters since JDK 20 according to its release notes; checked on 26 only. The\Xregex has existed since Java 9. - .NET's
StringInfoswitched to extended grapheme clusters in .NET 5 (a documented breaking change); run on .NET 10. grapheme_strlendepends on the ICU version bundled with PHP (78.3 here). Newer emoji can segment differently with an older ICU; Python 3.9'sunicodedatais Unicode 13.0, older than ICU 78.- Dart's grapheme support is the
characterspackage (used by Flutter); not run here because nothing could be installed. - MySQL, Node.js, browsers and Android were not run; their behaviour above is from their documentation.
Exercises
1. Predict, then check: the Korean flag ๐ฐ๐ท in JS .length, Python len, Swift count and PHP strlen.
2. Write a PHP function cut_for_column(string $s, int $maxBytes): string that never splits a grapheme, and test it on "๊ฐ์กฑ๐จโ๐ฉโ๐ง!" with 23 and 24.
Answers
1. Measured: 4, 2, 1, 8. Two regional indicator symbols (U+1F1F0, U+1F1F7), each one 4-byte, 2-unit code point, forming a single grapheme.
2. function cut_for_column(string $s, int $maxBytes): string { $next = 0; return grapheme_extract($s, $maxBytes, GRAPHEME_EXTR_MAXBYTES, 0, $next) ?: ""; } returned ๊ฐ์กฑ (6 bytes) for 23 and the 24-byte ๊ฐ์กฑ๐จโ๐ฉโ๐ง for 24, as shown above. The ?: "" covers the false that grapheme_extract returns when the input is not valid UTF-8.
Next
The last lecture asks the question every legacy import ends with: the file has no declared encoding, so can a program guess it? We measure how often it guesses right, how fast conversion is, and fix the chunked-reading bug from lecture 1.
- Previous: Text & Encoding lecture 4/6: CSV encodings, the BOM and Excel
- Next: Text & Encoding lecture 6/6: encoding detection is guesswork
- Earlier in the series: characters, code points and UTF-8 bytes, mojibake and how to fix it, Unicode normalization and filenames
- Related: JavaScript basics: types and equality, PHP basics: strings, C strings and buffer overflows, rendering CJK text into OG images, and the programming learning roadmap
FAQ
Why does JavaScript say an emoji has length 2?
JavaScript's .length counts UTF-16 code units, and every emoji outside the Basic Multilingual Plane needs two of them (a surrogate pair). ๐ is 2, ๐๐ฝ is 4 and the family emoji ๐จโ๐ฉโ๐ง is 8. [...s].length counts code points (1, 2, 5) and Intl.Segmenter counts what a user sees (1, 1, 1); all three were measured in JavaScriptCore on macOS 26.
How do I count characters the way a user sees them?
Count extended grapheme clusters: Swift's String.count, Intl.Segmenter in JavaScript, Java's BreakIterator or the \X regex, C#'s StringInfo.LengthInTextElements and PHP's grapheme_strlen all returned 1 for ๐จโ๐ฉโ๐ง and ๐ฏ๐ต. Python 3.9's standard library and Dart's core library have no grapheme API.
Can a VARCHAR(10) column hold 10 emoji?
It depends on what the database counts. SQLite ignores the 10 completely: VARCHAR(5) accepted a 16-character string. MySQL counts code points in VARCHAR(n), so 10 family emoji are 50 code points; and its old utf8 (utf8mb3) charset cannot store 4-byte characters at all, so use utf8mb4. The MySQL part is described from documentation, not run here.