← X character counter

74 conformance vectors for X’s weighted length

Open test data for anyone building a character counter for X — including the cases that have caught real counters, ours among them. 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, and so do Greek, Cyrillic, Hebrew, Arabic and Thai. Chinese, Japanese and Korean characters weigh 2, and so does an emoji. Every link costs a flat 23 whatever its real length.

That much is widely known and still not enough, in two places.

An emoji is one unit, not a string of code points. A complete emoji costs 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.

A link is not “everything up to the next space”. The full stop that ends a sentence is not part of the link before it, and is charged on its own. A bare example.com is a link, and so is main.rs, because X checks the ending against a list of 1,581 and .rs is on it. README.md is not, though .md is on the list too: x.com never treats a bare domain ending in .md as a link. An email address is not a link at all.

We shipped both bugs. Our counter over-counted joined emoji until 27 August 2026, 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.

Then the first version of this page made the same mistake one level up. It carried 49 vectors, our counters passed every one, and they were still wrong about links until October 2026: a full stop was taken into the link before it, whitehouse.gov was not a link, and an email address was charged 23 for its domain. The vectors could not say so, because the person who wrote the counter also wrote the vectors. What found it was running twitter-text, the library X publishes, over the same strings. Our counter is now a port of that library’s own patterns, and 22 of the vectors below are the cases we had wrong.

The port was wrong too, and we found it on 9 October 2026 by pasting five strings into x.com’s own composer before announcing it. The site no longer counts quite the way the library X publishes does: it never treats a bare README.md as a link, it knows seven domain endings the library does not, its emoji table is newer, and it lets a link follow the mark U+FEFF. Our counter now counts what x.com counts, and three of the vectors below are those cases.

Which is the reason this page exists. Counter tools all claim accuracy, and the ones we measured publish nothing you can check them with. So here is ours, in a form that can falsify it.

That is a lot to read. Five of them catch the mistakes we have found in real counters, ours included — start there if you just want to know whether the counter you use is sound.

Get the data

curl -s https://postsiete.com/tools/x-vectors.json | jq '.vectors | length'
# 74

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);
  }
}

How to falsify these

The expected values are derived from the rules x.com applies 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.

Arithmetic that reads well can still describe a rule that does not exist, which is how version 1 went wrong. So every expected value is also checked against X itself: on 9 October 2026 the vectors were run through the counting code of x.com’s own composer, and it agrees with all 74. The library X publishes does not, and that is stated here so nobody has to discover it: twitter-text as npm installs it today fails four, a bare README.md, example.music and two emoji, because x.com has moved on since the library’s last release in 2020.

If you believe a vector is wrong, the fastest disproof is to paste the string into X’s composer, without sending it, and read the counter, or to post it and see whether it is accepted at the length claimed. We would rather hear it than be wrong — tell us.

The vectors

The tables are for reading: a space is drawn as ·, a line break as \n and a joiner as ZWJ. Copy strings from the JSON, not from here.

basic · 7

Plain text in the light ranges. If these fail, nothing else matters.

NameInputWeightWhy
ascii-hellohello5Five Latin letters, all weight 1.
ascii-sentenceThe·quick·brown·fox·jumps·over·the·lazy·dog.4444 characters, every one inside the first light range.
newlines-countone\ntwo\nthree13Line feeds are U+000A — inside the light range, and they are charged, not ignored.
latin-accents-precomposedcafé4U+00E9 is a single code point below 0x1100, so 1 like the rest.
cyrillicпривет6Cyrillic sits at U+0400–U+04FF, inside the first light range. Weight 1, which surprises people who assume "non-Latin means 2".
hebrewשלום4U+05D0–U+05EA, inside the first light range.
thaiสวัสดี6Thai 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.

heavy · 4

Characters outside the light ranges, which cost 2. CJK is the common case and the reason a 200-character Japanese post does not fit.

NameInputWeightWhy
japaneseこんにちは10Five kana at U+3053 upward, all above 0x10FF, so 2 each.
chinese你好世界8Four CJK ideographs, 2 each.
korean안녕하세요10Five precomposed Hangul syllables at U+AC00 upward, 2 each.
mixed-latin-cjkhello·こんにちは165 Latin (5) + 1 space (1) + 5 kana at 2 (10) = 16.

emoji · 12

The one-emoji-one-unit rule: an emoji in X's table costs 2 however many code points build it. Weighting the code points one at a time is too high on every emoji built from more than one.

NameInputWeightWhy
emoji-simple😀2One emoji, weight 2. Also the case everyone already passes.
emoji-with-variation-selector❤️2U+2764 plus U+FE0F. Two code points, ONE emoji, so 2. Weighted one code point at a time it is 2 + 2 = 4.
emoji-skin-tone👍🏽2Thumbs up plus a skin-tone modifier is one emoji at 2, not two emoji at 4.
emoji-flag🇨🇴2Two regional indicators forming one flag: 2, not 4. A per-code-point counter reports 4.
emoji-zwj-mechanic🧑ZWJ🔧2Person + 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-newer-than-the-published-table🐦ZWJ⬛2Bird + ZWJ + black large square is the black bird, from Emoji 15.0, and x.com charges it as one emoji: 2. The last emoji table X published, twemoji-parser 14.0.0 from 2022, does not know it, so a counter built on that table charges the pieces: 2 + 1 + 2 = 5.
emoji-zwj-family👨ZWJ👩ZWJ👧ZWJ👦2Four 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😀😀4Two separate emoji, 2 each. Guards against a cluster walker that swallows neighbours.
emoji-in-proseship·it·🚀10"ship it " is 8 at weight 1, plus one emoji at 2.
emoji-keycap1️⃣2Digit + U+FE0F + U+20E3 is one keycap emoji at 2, not three units.
emoji-keycap-without-selector1⃣2Digit + U+20E3 with no U+FE0F between them is still the keycap emoji at 2. Counted apart they are 1 + 2 = 3.
emoji-joined-but-not-a-sequence😀ZWJ😀5A joiner between two emoji that do not form a known sequence joins nothing: 2 + 1 + 2 = 5. One unit at 2 is only for sequences in the emoji table.

url · 21

The flat 23, and where X finds a link: with or without a protocol, on any of the 1,581 endings x.com recognises, stopping before the punctuation that ends a sentence.

NameInputWeightWhy
url-https-shorthttps://x.com23Flat 23 regardless of length — 13 characters charged as 23. Links can cost MORE than they are long.
url-https-longhttps://example.com/a/very/long/path?with=query&and=more#fragment23Same flat 23. 65 characters charged as 23.
url-www-no-schemewww.example.com23A www. host with no scheme is still linkified.
url-bare-domainexample.com23THE 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-pathexample.com/pricing23The path is part of the link, so still one 23 rather than 23 plus the path.
url-in-proseread·this:·https://example.com·now38"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-linkshttps://a.com·https://b.com4723 + 1 space + 23 = 47.
url-adjacent-to-cjkこんにちは·https://example.com345 kana at 2 (10) + 1 space + 23 = 34. Checks that the link split does not lose the weighting of the text around it.
url-then-full-stopRead·more:·https://example.com/post.35"Read more: " is 11, the link is a flat 23, and the full stop is 1: 35. A path may not END on a full stop, so the stop belongs to the sentence. A matcher that runs to the next space takes it into the link and reports 34: one short, whenever a sentence ends with a link.
url-then-commaSee·https://example.com,·then·reply39"See " is 4, the link is 23, and ", then reply" is 12: 39. The comma is not part of the link.
url-in-brackets(https://example.com)251 + 23 + 1 = 25. The closing bracket has no opening one inside the link, so it stays outside.
url-then-question-markIs·it·https://example.com/pricing?30"Is it " is 6, the link is 23, the question mark is 1: 30. A query needs something after its "?", so a bare one ends the sentence, not the link.
url-brackets-inside-the-pathhttps://en.wikipedia.org/wiki/Primer_(film)23Brackets that open and close inside the path are part of it, which is what keeps a Wikipedia link whole. One link, 23.
url-full-stop-is-the-281staaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa·https://example.com.281256 + 1 space + 23 + 1 full stop = 281, one over the limit. A counter that takes the stop into the link says 280 and calls this postable. This is the vector that costs somebody a rejected post.
url-markdown-ending-with-protocolhttps://example.md23With a protocol, .md is an ending like any other (it is the country ending of Moldova), so this is a link at 23. The exception x.com makes for .md applies to bare domains only. Compare not-a-url-markdown-file.
url-ending-newer-than-the-published-listexample.music23.music is one of seven endings x.com recognises that are missing from the list X published in twitter-text, so this bare domain is a link at 23. A counter that uses the published list sees thirteen ordinary characters and reports 13.
url-government-endingwhitehouse.gov23A bare domain on an ending outside the handful everyone remembers. x.com recognises 1,581 of them; a counter with a short list of its own reports 14.
url-missing-space-makes-a-linkthe·end.Next·sentence36"the " is 4, "end.Next" is a link at 23 because .next is a generic ending and case does not matter, and " sentence" is 9: 36. A missing space after a full stop can manufacture a link.
url-upper-caseEXAMPLE.COM23Link matching ignores case, so capitals change nothing: 23.
url-non-latin-with-protocolhttp://пример.рф23With a protocol, a domain in any script is a link: .рф is the Cyrillic country ending of Russia. 23. Compare not-a-url-non-latin-bare.
url-bare-domain-query-left-outexample.com?q=127Without a protocol a query is only kept when a path comes before it. Here the link is example.com at 23 and "?q=1" is 4 more: 27. With https:// in front the whole string is one link at 23.

not-a-url · 15

Text that must NOT be charged 23: an email address, an ending that is not one, a domain glued to a "#". A spurious 23 sends an author rewriting copy that already fitted.

NameInputWeightWhy
not-a-url-ege.g.·this·is·prose18"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-filenamesee·index.js·for·details24.js is not a domain ending, so this is 24 plain characters. A counter matching any dotted token charges 23 for the filename. Compare not-a-url-markdown-file, where .md is an ending and the file name is still not charged as a link.
not-a-url-markdown-fileI·updated·README.md19"I updated " is 10 and README.md is 9 ordinary characters: 19. .md is the country ending of Moldova, and X's published library makes README.md a link at 23, but x.com does not: there a bare domain ending in .md is a file name, not a link. With https:// in front it is a link again; see url-markdown-ending-with-protocol.
not-a-url-versionversion·2.78.0·shipped22Neither "78" nor "0" is a domain ending, so a version string is 22 ordinary characters.
not-a-url-sentence-endIt·is·done.Then·we·ship.24"then" is not a domain ending, so the missing space manufactures nothing and this is 24 characters. That is luck rather than a rule: see url-missing-space-makes-a-link.
not-a-url-almost-tldexample.coma12.coma is not a domain ending, 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-ellipsiswait...what11Consecutive dots are not a hostname.
not-a-url-emailmail·jane@example.com·today27A domain straight after "@" is not a link, so the address is 16 ordinary characters: 5 + 16 + 6 = 27. Charging the domain 23 reports 39 and tells an author a post that fits is too long.
not-a-url-after-hash#example.com12A domain straight after "#" is not a link. The same goes for "$", "@", a letter or a digit. Twelve characters, 12.
not-a-url-no-endinghttp://localhost:300021A protocol is not enough: the host must finish with a recognised ending, and localhost has none. 21 characters, 21.
not-a-url-other-schemeftp://example.com17Only http and https start a link, and a bare domain may not directly follow a slash, so nothing here is one. 17 characters, 17.
not-a-url-www-without-endingwww.example11"www." does not make a link; a recognised ending does, and "example" is not one. Eleven characters, 11.
not-a-url-label-too-longaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com68One part of a domain may be at most 63 characters. This one is 64, so the whole string is 68 ordinary characters. At 63 it would be a link at 23.
not-a-url-underscore-in-the-namemy_site.io10An underscore is allowed in a subdomain but not in the name next to the ending, and a bare domain may not start straight after one, so neither "my_site.io" nor "site.io" is a link. Ten characters, 10.
not-a-url-non-latin-bareпример.рф9Without a protocol only an ASCII domain is linked. Eight Cyrillic letters and a dot, all at weight 1: 9. Compare url-non-latin-with-protocol.

normalisation · 2

The same visible string must cost the same however it was encoded.

NameInputWeightWhy
nfc-decomposed-accentcafé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가2Two 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.

boundary · 8

The exact edges of the published ranges. Off-by-one lives here.

NameInputWeightWhy
boundary-last-light-charჿ1U+10FF is the last code point of the first light range. Inclusive, so 1.
boundary-first-heavy-charᄀ2U+1100 is one past the range. 2. Together with the previous case this pins the edge in both directions.
boundary-general-punctuation-startEN QUAD1U+2000 opens the second light range.
boundary-zwj-aloneZWJ1A 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-rangeLRM2U+200E, the left-to-right mark, is one past the second range and costs 2. Invisible and expensive.
boundary-en-dash–1U+2013 is inside 0x2010–0x201F. Dashes and smart quotes are cheap; that is the point of the third range.
boundary-prime′1U+2032 opens the fourth range, 0x2032–0x2037.
boundary-just-past-fourth-range‸2One past the last light range.

structural · 5

Empty strings, whitespace, and the degenerate inputs that produce a crash rather than a wrong number.

NameInputWeightWhy
empty(empty)0Nothing costs nothing.
single-space·1U+0020 is inside 0x0000–0x10FF, so 1.
exactly-280-asciiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa280The limit itself. Fits — the comparison is <=, not <.
over-by-oneaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa281One over. The two cases together pin which way the boundary falls.
cjk-140-fills-280漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢280140 ideographs at 2 each is exactly the limit — the arithmetic behind "a Japanese post gets half as many characters".

What this is not

Not official, and not the first. X publishes twitter-text under Apache 2.0 with its own conformance tests, and our counter runs the 1,706 of them that concern weighted length and links. If you want the canonical suite, use theirs. What is here is narrower: JSON a browser can fetch, the arithmetic attached to every expected value, and the cases around what follows a link and what is not one.

Not timeless. The rules are x.com’s as read on 9 October 2026. X has changed its composer since twitter-text’s last release without publishing the changes, and it can do so again. Emoji newer than Emoji 15.0 are charged by the piece because x.com charges them that way today. When X changes the rules, this page is wrong until somebody notices, which is what the section above is for.

Sources