Theory: Tokenization¶
This book's own intro admits that most AI content stalls at "what a token is" and never gets past it. Fair — here's the part that usually gets skipped: the actual merge algorithm that builds a token vocabulary, why two Claude models released eight months apart can tokenize the same sentence into a different number of pieces, and why that number is worse for code and non-English text than for plain English prose. None of this is Claude-specific mechanics dressed up as theory — it's the same algorithm class (byte-pair encoding, or a close variant) behind every current frontier model's tokenizer, Claude's included.
A token is not a word, and not fixed in advance¶
The model never reads characters. Every request is converted to a sequence of integers, each one an index into a fixed vocabulary — tens of thousands to a few hundred thousand entries, depending on the model family. Those entries aren't linguistic units chosen by a linguist. They're byte sequences chosen by an algorithm that ran once, offline, over a training corpus, looking for whatever chunks appeared together often enough to be worth a dedicated slot.
The algorithm is byte-pair encoding (BPE): start with every word broken into individual characters, find the adjacent pair that occurs most often across the whole corpus, merge it into one symbol, and repeat until the vocabulary hits its target size. Here it is on the classic four-word toy corpus from the original BPE paper — real output, not a hand-typed example:
from collections import Counter
corpus = {
("l","o","w","</w>"): 5,
("l","o","w","e","r","</w>"): 2,
("n","e","w","e","s","t","</w>"): 6,
("w","i","d","e","s","t","</w>"): 3,
}
def get_pair_counts(corpus):
pairs = Counter()
for word, freq in corpus.items():
for a, b in zip(word, word[1:]):
pairs[(a, b)] += freq
return pairs
def merge_pair(pair, corpus):
a, b = pair
merged = a + b
new_corpus = {}
for word, freq in corpus.items():
new_word, i = [], 0
while i < len(word):
if i < len(word) - 1 and word[i] == a and word[i + 1] == b:
new_word.append(merged); i += 2
else:
new_word.append(word[i]); i += 1
new_corpus[tuple(new_word)] = freq
return new_corpus
for step in range(6):
pairs = get_pair_counts(corpus)
best = max(pairs, key=pairs.get)
print(f"merge {step+1}: {best[0]!r}+{best[1]!r} -> {best[0]+best[1]!r} (count={pairs[best]})")
corpus = merge_pair(best, corpus)
merge 1: 'e'+'s' -> 'es' (count=9)
merge 2: 'es'+'t' -> 'est' (count=9)
merge 3: 'est'+'</w>' -> 'est</w>' (count=9)
merge 4: 'l'+'o' -> 'lo' (count=7)
merge 5: 'lo'+'w' -> 'low' (count=7)
merge 6: 'n'+'e' -> 'ne' (count=6)
resulting word segmentations:
low </w> (freq=5)
low e r </w> (freq=2)
ne w est</w> (freq=6)
w i d est</w> (freq=3)
Six merges in, est</w> — the "-est" superlative suffix plus a word
boundary — is already its own symbol, because "newest" and "widest" pushed
e+s to the top of the frequency count. "low" earned a merged symbol
too, from lower plus its own occurrences. Run this over billions of real
words instead of four toy ones, stop at tens of thousands of merges
instead of six, and this is the whole algorithm. There's no dictionary, no
part-of-speech tagging, no notion of a "word" at all going in — only pair
frequency, over and over, until the budget runs out.
The consequence that matters: a token is not a property of a string. It's a property of a (string, trained vocabulary) pair. Change the vocabulary and the same sentence tokenizes into a different sequence of a different length.
Different Claude models, different tokenizers¶
Claude's tokenizer isn't published as a downloadable merge table the way
some open tokenizers are, and it isn't the same tokenizer OpenAI's models
use — tiktoken will silently give you the wrong count for a Claude
prompt, sometimes off by 15–20% on plain text and by much more on code.
Anthropic's own guidance is blunt about it: never estimate Claude token
counts with tiktoken — call the counting endpoint instead.
import anthropic
client = anthropic.Anthropic()
resp = client.messages.count_tokens(
model="claude-opus-4-8",
messages=[{"role": "user", "content": open("CLAUDE.md").read()}],
)
print(resp.input_tokens)
That call matters more than it looks, because the vocabulary isn't fixed across a vendor's own model lineup either. Opus 4.7 shipped a new tokenizer, and the same input text produces roughly 1×–1.35× as many tokens under it as under the older Opus 4.6 tokenizer did — same words, more pieces, because the merge table changed. Sonnet 5 went further: its new tokenizer produces about 30% more tokens for identical text than Sonnet 4.6's did. Fable 5 and Opus 4.8 reuse the tokenizer introduced with Opus 4.7, so migrating between those specific models leaves counts roughly where they were — but anyone jumping from Opus 4.6 or earlier straight to Fable 5 inherits that same 1×–1.35× inflation.
None of this shows up as an error. A max_tokens value that comfortably
fit a response on the old model can quietly start truncating output on the
new one, and a context-window budget sized against the old token count can
run out sooner than expected — both because the text got longer in tokens
without changing a single character. The fix isn't a multiplier applied by
hand; it's re-running count_tokens against the new model on your own
prompts, because the ratio isn't constant across content types.
Why code and other languages cost more tokens per character¶
Merges get created when a pair shows up often enough in the training corpus to be worth a slot — and that corpus is overwhelmingly English prose. The vocabulary's cheapest, most-compressed entries are common English words and word fragments, because those are what the merge algorithm saw over and over.
Code doesn't compress the same way. Indentation runs, camelCase and snake_case boundaries, repeated punctuation, and identifiers that don't look like English words don't line up with the merge patterns English prose produced, so the same visible character count in a source file typically breaks into more tokens than it would in a paragraph of prose. Non-Latin scripts — Chinese, Japanese, Korean, Hindi, Arabic — see the same effect for a more basic reason: the merges that would compress them efficiently were never as frequent in the training mix, so encoding often falls back toward one token per character, or even per byte, instead of per common word-fragment. A sentence that reads as "the same length" to a person can cost noticeably more tokens in one script than another. That's a property of what the vocabulary was trained on, not of the language itself — a tokenizer trained on a different corpus mix would compress differently.
What this explains, not just what it costs¶
The token boundary is also why some famously "dumb" model mistakes aren't
about reasoning at all. Asking a model to count the letters in
"strawberry" is hard for the same reason the toy corpus above merged
e+s before it ever considered a single letter: the model's input is
whatever token IDs straw + berry (or however the trained vocabulary
happens to split it) turned into — the individual letters were never a
unit it was shown. Recovering "how many r's" means reasoning about the
sub-token structure indirectly, not reading characters off a tape the way
a person would.
It's also why trailing whitespace inside a tool call's string parameters
became a real, documented compatibility break between model generations —
older Claude models stripped a trailing newline a tool's input string
carried; 4.5-and-later models started preserving it, because the boundary
between "text" and "token" isn't the same as the boundary between
"visible character" and "invisible character." Code that did exact string
matching against a tool's input value (if name == "foo") had to add
.rstrip() to keep working, because a token-level model doesn't
distinguish "foo" from "foo\n" the way a naive string comparison does.
And it's why context windows, max_tokens, and pricing are all denominated
in tokens rather than characters or words in the first place: tokens are
the actual unit the model consumes and produces. A context window
described as "1M" doesn't hold a fixed number of English words, a fixed
number of lines of code, or a fixed number of Japanese characters — it
holds however many tokens that specific tokenizer turns that specific
content into, and that number moves depending on what the content is and
which model is reading it.
The practical rule¶
Don't estimate token usage, cost, or context-window headroom from
character counts, word counts, or a number remembered from a different
model. Call count_tokens against the model you're actually going to run,
on the actual content you're going to send it, and re-run it after any
model migration — the tokenizer is part of what "migrating" changes,
usually silently.