Skip to content
Spellkit

Why Counting Characters Is Harder Than It Looks

A "character" means three different things to three different systems — code point, UTF-16 code unit, or grapheme cluster — which is exactly why Twitter, SMS, and database columns each report a different length for the same string.

Paste 👨‍👩‍👧‍👦 into three different tools and you can get three different lengths back, and none of them is wrong. "Character" isn't one definition — it's shorthand for at least three distinct ways of slicing a string, and they only agree on plain ASCII.

Three ways to count "one character"

A Unicode code point is a single numbered entry in the Unicode table (U+0041 for A, U+1F44D for 👍). A UTF-16 code unit is a 16-bit storage slot — most code points fit in one, but anything above U+FFFF needs two, called a surrogate pair. A grapheme cluster is what a reader perceives as one visual character, which can be built from several code points glued together. JavaScript's .length counts UTF-16 code units. Python's len() counts code points. Neither counts grapheme clusters unless you ask specifically.

Emoji: one code point, two code units

👍 (U+1F44D) is a single code point, but it sits above U+FFFF, so UTF-16 stores it as a surrogate pair — two code units. That's why "👍".length returns 2 in JavaScript even though it's clearly one emoji. Splitting the string by code point instead ([..."👍"] or Array.from) correctly gives 1.

The family emoji: seven code points, one glyph

👨‍👩‍👧‍👦 looks like a single symbol but is actually four person emoji — 👨 👩 👧 👦 — joined by three invisible zero-width joiner (ZWJ, U+200D) characters: person, ZWJ, person, ZWJ, person, ZWJ, person. That's seven code points forming one grapheme cluster. Count it by code point and you get 7. Count it by UTF-16 code unit and you get 11 (each of the four emoji is a surrogate pair, plus three single-unit ZWJs). Count it the way a reader does — one family — and you get 1. All three answers are "correct" depending on which layer of the string you're measuring.

Combining accents: é can be one code point or two

The letter é can be encoded two different ways that render identically: as the single precomposed code point U+00E9 ("Latin small letter e with acute"), or as plain e (U+0065) followed by a separate combining acute accent (U+0301) — two code points stacked into one visual glyph. Text copied from different systems (macOS historically favors the decomposed form) can look identical on screen while having a different code point count, which is a real source of "these strings look the same but don't match" bugs in search and deduplication.

CJK: one code point, three bytes

A Korean or Chinese character like 한 or 中 is refreshingly simple by comparison — one code point, one grapheme cluster, no surrogate pairs, no combining marks. But it still costs more in storage: in UTF-8, code points in that range encode to 3 bytes each, versus 1 byte for ASCII. Spellkit's Character Counter shows this split directly — it counts characters by code point (so 한 is 1 character, matching what most people mean), but reports UTF-8 byte size separately, which is where a form with a byte limit and a form with a character limit start disagreeing on the same Korean text.

Why Twitter, SMS, and databases all disagree

Each system picked a count for its own reasons, not a shared standard:

  • X/Twitter counts by code point but weights some ranges as 2 — most Latin, Cyrillic, and Arabic characters cost 1 toward the 280 limit, while CJK Unified Ideographs and Hangul syllables cost 2, so a post in Korean or Chinese effectively caps at around 140 characters, not 280.
  • SMS counts by a legacy 7-bit alphabet: a single segment holds 160 characters in the GSM 7-bit encoding, but the moment the message contains one character outside that alphabet — any emoji, most non-Latin scripts — the whole message switches to 16-bit UCS-2 encoding and the limit drops to 70 characters per segment (67 once it splits into multiple linked segments).
  • Database columns often count differently depending on engine and encoding. MySQL's classic VARCHAR(191) convention (instead of a rounder 255) exists because older InnoDB capped an indexed column's prefix at 767 bytes, and 191 × 4 bytes (the max per character in utf8mb4) just fits under that ceiling while 255 × 4 doesn't.

None of these three is measuring "characters" incorrectly — they're each optimizing for their own encoding and history, and the mismatch only becomes visible once emoji, accents, or CJK text push a string past the boundary where code points, code units, and bytes stop lining up. Checking a string's code point count, no-space count, and UTF-8 byte size side by side is the fastest way to see which limit you're actually about to hit — that's what the Character Counter is for.