Open test data for anyone building a character counter for X — including the cases almost everybody gets wrong. Take them, run them against your own code, and tell us if we are the ones who are wrong.
X does not count characters. It assigns each character a weight and allows 280 weight per post. Latin letters, digits and common punctuation weigh 1; nearly everything else — CJK, Cyrillic, Arabic, Devanagari, every emoji — weighs 2. Every URL costs a flat 23 whatever its real length.
That much is widely known and still not enough. The rule is grapheme clusters, not code points: a complete emoji is one unit at weight 2 however many code points it is built from. A four-person family emoji is seven code points joined by zero-width joiners. Counting code points and weighting them gives 11. X charges 2.
We shipped that bug. Our counter over-counted joined emoji for weeks, and the test we had comparing our browser copy against our server copy passed every single run — because both copies were wrong in the same way. Two implementations agreeing with each other is evidence of consistency, not of correctness.
Which is the reason this page exists. Every counter in this category claims accuracy and none of them publish anything you can check. So here is ours, in a form that can falsify it.
curl -s https://postsiete.com/tools/x-vectors.json | jq '.vectors | length' # 49
Served as JSON with Access-Control-Allow-Origin: *, so a browser-based test suite can fetch it directly. MIT licensed. Each vector carries a why giving the arithmetic, so a disagreement points at a specific claim rather than at a number.
const { vectors } = await (await fetch(
'https://postsiete.com/tools/x-vectors.json'
)).json();
for (const v of vectors) {
const got = yourWeightedLength(v.text);
if (got !== v.expected) {
console.log(v.name, 'expected', v.expected, 'got', got, '—', v.why);
}
}
The expected values are derived from X’s published rules and stated as arithmetic — they are not snapshots of any implementation, ours included. That matters: a suite generated from an implementation can only ever prove that the implementation agrees with itself.
If you believe a vector is wrong, the fastest disproof is to post the string to X and see whether it is accepted at the length claimed. We would rather hear it than be wrong — tell us.
Plain text in the light ranges. If these fail, nothing else matters.
| Name | Input | Weight | Why |
|---|---|---|---|
| ascii-hello | hello | 5 | Five Latin letters, all weight 1. |
| ascii-sentence | The·quick·brown·fox·jumps·over·the·lazy·dog. | 44 | 44 characters, every one inside the first light range. |
| newlines-count | one\ntwo\nthree | 13 | Line feeds are U+000A — inside the light range, and they are charged, not ignored. |
| latin-accents-precomposed | café | 4 | U+00E9 is a single code point below 0x1100, so 1 like the rest. |
| cyrillic | привет | 6 | Cyrillic sits at U+0400–U+04FF, inside the first light range. Weight 1, which surprises people who assume "non-Latin means 2". |
| hebrew | שלום | 4 | U+05D0–U+05EA, inside the first light range. |
| thai | สวัสดี | 6 | Thai is U+0E00–U+0E7F, inside the light range. Six code points, six units — note this is more than the four grapheme clusters a reader sees. |
Characters outside the light ranges, which cost 2. CJK is the common case and the reason a 200-character Japanese post does not fit.
| Name | Input | Weight | Why |
|---|---|---|---|
| japanese | こんにちは | 10 | Five kana at U+3053 upward, all above 0x10FF, so 2 each. |
| chinese | 你好世界 | 8 | Four CJK ideographs, 2 each. |
| korean | 안녕하세요 | 10 | Five precomposed Hangul syllables at U+AC00 upward, 2 each. |
| mixed-latin-cjk | hello·こんにちは | 16 | 5 Latin (5) + 1 space (1) + 5 kana at 2 (10) = 16. |
The one-emoji-one-unit rule. This is where most implementations are wrong, and the failure is always in the same direction: too high.
| Name | Input | Weight | Why |
|---|---|---|---|
| emoji-simple | 😀 | 2 | One emoji, weight 2. Also the case everyone already passes. |
| emoji-with-variation-selector | ❤️ | 2 | U+2764 plus U+FE0F. Two code points, ONE emoji, so 2 — not 1+1 and not 2+1. |
| emoji-skin-tone | 👍🏽 | 2 | Thumbs up plus a skin-tone modifier is one emoji at 2, not two emoji at 4. |
| emoji-flag | 🇨🇴 | 2 | Two regional indicators forming one flag: 2, not 4. A per-code-point counter reports 4. |
| emoji-zwj-mechanic | 🧑ZWJ🔧 | 2 | Person + ZWJ + wrench = one emoji at 2. Per code point it is 200+100+200 = 5 after dividing by scale, and 5 is the number reported in twitter-text#369. |
| emoji-zwj-family | 👨ZWJ👩ZWJ👧ZWJ👦 | 2 | Four people joined by three ZWJs — seven code points, one emoji, 2. Per code point it is (200*4 + 100*3)/100 = 11. If you get 11, you fell through to the per-code-point path. If you get 7, you counted code points and forgot the weights. If you get 11 in Python, `len()` gave you 7 and something else went wrong too. |
| emoji-two-adjacent | 😀😀 | 4 | Two separate emoji, 2 each. Guards against a cluster walker that swallows neighbours. |
| emoji-in-prose | ship·it·🚀 | 10 | "ship it " is 8 at weight 1, plus one emoji at 2. |
| emoji-keycap | 1️⃣ | 2 | Digit + U+FE0F + U+20E3 is one keycap emoji at 2, not three units. |
The flat 23. The WEIGHT is canonical; which substrings are links is where honest implementations differ — see not-a-url.
| Name | Input | Weight | Why |
|---|---|---|---|
| url-https-short | https://x.com | 23 | Flat 23 regardless of length — 13 characters charged as 23. Links can cost MORE than they are long. |
| url-https-long | https://example.com/a/very/long/path?with=query&and=more#fragment | 23 | Same flat 23. 64 characters charged as 23. |
| url-www-no-scheme | www.example.com | 23 | A www. host with no scheme is still linkified. |
| url-bare-domain | example.com | 23 | THE ONE THAT UNDERCOUNTS. No scheme, no www., still a link, still 23. A matcher requiring http(s):// or www. reports 11 and tells the author a post fits that the API will reject. |
| url-bare-domain-with-path | example.com/pricing | 23 | The path is part of the link, so still one 23 rather than 23 plus the path. |
| url-in-prose | read·this:·https://example.com·now | 38 | "read this: " is 11 characters at weight 1, the link is a flat 23, and " now" is 4. 11 + 23 + 4 = 38. The text either side of a link must keep its own weighting. |
| url-two-links | https://a.com·https://b.com | 47 | 23 + 1 space + 23 = 47. |
| url-adjacent-to-cjk | こんにちは·https://example.com | 34 | 5 kana at 2 (10) + 1 space + 23 = 34. Checks that the link split does not lose the weighting of the text around it. |
Text that must NOT be charged 23. Dotted tokens are everywhere in ordinary prose, and a spurious link is a worse error than a missed one.
| Name | Input | Weight | Why |
|---|---|---|---|
| not-a-url-eg | e.g.·this·is·prose | 18 | "e.g." is not a link. Charging it 23 would report 37 for an 18-unit sentence and send an author rewriting copy that fits. |
| not-a-url-filename | see·index.js·for·details | 24 | .js is not in the TLD list, so this is 24 plain characters. A counter matching any dotted token charges 23 for the filename. |
| not-a-url-version | version·2.78.0·shipped | 22 | A version string is not a domain. The leading-digit guard is what stops this. |
| not-a-url-sentence-end | It·is·done.Then·we·ship. | 24 | A missing space after a full stop must not manufacture a domain out of two words. |
| not-a-url-almost-tld | example.coma | 12 | .coma is not a TLD, so this is twelve ordinary characters — which means "example.com" costs 23 and "example.coma" costs 12, and ADDING a character made the post cheaper. Surprising, correct, and the clearest demonstration that link detection is decided on the whole token rather than incrementally. |
| not-a-url-ellipsis | wait...what | 11 | Consecutive dots are not a hostname. |
The same visible string must cost the same however it was encoded.
| Name | Input | Weight | Why |
|---|---|---|---|
| nfc-decomposed-accent | café | 4 | "cafe" + U+0301 combining acute normalises under NFC to "café", four code points at weight 1. Without normalisation this is 5, so the same visible word costs differently depending on which editor produced it. |
| nfc-korean-decomposed | 가 | 2 | Two conjoining jamo normalise under NFC to the single syllable 가 (U+AC00), which is above 0x10FF and so costs 2. Unnormalised, the jamo are also above 0x10FF and would cost 4. |
The exact edges of the published ranges. Off-by-one lives here.
| Name | Input | Weight | Why |
|---|---|---|---|
| boundary-last-light-char | ჿ | 1 | U+10FF is the last code point of the first light range. Inclusive, so 1. |
| boundary-first-heavy-char | ᄀ | 2 | U+1100 is one past the range. 2. Together with the previous case this pins the edge in both directions. |
| boundary-general-punctuation-start | 1 | U+2000 opens the second light range. | |
| boundary-zwj-alone | ZWJ | 1 | A bare ZWJ is the last code point of the second range, weight 1. This is the value that makes the family emoji 11 rather than 14 when counted wrongly, so it is worth pinning on its own. |
| boundary-just-past-second-range | | 2 | U+200E, the left-to-right mark, is one past the second range and costs 2. Invisible and expensive. |
| boundary-en-dash | – | 1 | U+2013 is inside 0x2010–0x201F. Dashes and smart quotes are cheap; that is the point of the third range. |
| boundary-prime | ′ | 1 | U+2032 opens the fourth range, 0x2032–0x2037. |
| boundary-just-past-fourth-range | ‸ | 2 | One past the last light range. |
Empty strings, whitespace, and the degenerate inputs that produce a crash rather than a wrong number.
| Name | Input | Weight | Why |
|---|---|---|---|
| empty | (empty) | 0 | Nothing costs nothing. |
| single-space | · | 1 | U+0020 is inside 0x0000–0x10FF, so 1. |
| exactly-280-ascii | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 280 | The limit itself. Fits — the comparison is <=, not <. |
| over-by-one | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 281 | One over. The two cases together pin which way the boundary falls. |
| cjk-140-fills-280 | 漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢 | 280 | 140 ideographs at 2 each is exactly the limit — the arithmetic behind "a Japanese post gets half as many characters". |
emojiParsingEnabled — the weighted length “considers all emoji as a single code point … including longer grapheme clusters combined by zero-width joiners.”