Fundamentals

Count Tokens in Python Before You Send

Measure any prompt with tiktoken before it costs you anything: get the right encoding, count strings and message lists, and block oversized requests.

By the end of this guide you will have a small Python module that tells you exactly how large a request is before the request leaves your laptop — a plain string, a full chat message list, or a 40-page document you are about to paste into a prompt. It takes about fifteen minutes to set up and it costs nothing to run, because the measurement happens locally. Once it is in place you can refuse, trim or split anything that is too big, instead of finding out from an error message or a surprise line on your bill.

This is one guide in Managing AI API Costs and Tokens. Counting is the first habit in that series: every other cost technique — pricing a call, caching a reply, capping a month — depends on knowing the size of what you are about to send.

Prerequisites

You need Python 3.10 or newer and an activated virtual environment. If you have not made one yet, Create a Python Virtual Environment for AI walks through it. Check your version:

python --version

Install the counting library plus the OpenAI SDK, which you need only for the final step:

pip install "tiktoken>=0.7" "openai>=1.40" "python-dotenv>=1.0"
pip freeze > requirements.txt

tiktoken is OpenAI's own tokeniser — the same code that turns text into tokens on their side, published as a Python package you run yourself. Counting needs no API key. The last step does make one real call, so put your key in a .env file (a plain text file of NAME=value lines that never gets committed):

OPENAI_API_KEY=sk-your-key-here

Then add that file to .gitignore before you write another line:

echo ".env" >> .gitignore

A key in a public repository gets scraped and spent by strangers, often within the hour. One line of .gitignore prevents it.

Step 1 — Get the encoding that matches your model

A token is a chunk of text — usually a short word, part of a longer word, or a punctuation mark — and an encoding is the specific vocabulary that decides where those chunks begin and end. Different model families use different vocabularies, so the same sentence can come to a different number of tokens depending on which model you send it to. Getting the encoding right is the whole game; everything after this is arithmetic.

Never guess the encoding name. Ask tiktoken to look it up from the model name, and keep a fallback for model names your installed version has not heard of yet:

import tiktoken


def encoding_for(model: str):
    """Return the tokeniser tiktoken uses for a model, with a safe fallback."""
    try:
        return tiktoken.encoding_for_model(model)
    except KeyError:
        # Model not in this version's lookup table yet.
        return tiktoken.get_encoding("o200k_base")


for name in ("gpt-4o-mini", "gpt-4o", "text-embedding-3-small"):
    print(f"{name:24} -> {encoding_for(name).name}")

Run it and you will see the newer chat models resolve to o200k_base while the embedding model resolves to cl100k_base. That difference matters: counting an embedding input with a chat encoding gives you a number that is close but not correct.

The first call downloads the vocabulary file for that encoding and caches it on disk. Every call after that is offline and takes microseconds.

Step 2 — Count a plain string

With an encoding in hand, counting is two lines. encode turns text into a list of integers, one per token, and len counts them. Decoding a single id back to text shows you exactly where the tokeniser drew its boundaries, which is the fastest way to build intuition:

import tiktoken

enc = tiktoken.encoding_for_model("gpt-4o-mini")

text = "Counting tokens before you send keeps the bill predictable."
ids = enc.encode(text)

print("token ids:", ids[:8])
print("pieces:   ", [enc.decode([i]) for i in ids[:8]])
print("tokens:   ", len(ids))
print("words:    ", len(text.split()))

Look at the pieces line. Most of them arrive as a leading space plus a whole word, because the vocabulary was built from real text and common words earned their own entry. Longer or rarer words split into two or three fragments.

How a Python string becomes a token count A left-to-right data flow: your text string enters the encoding's encode method, which returns a list of integer token ids, and the length of that list is the token count. Your text a Python string encode() runs on your laptop List of token ids one integer per token len(ids) the number you act on No network request and no API key needed.
Counting is a local pipeline: text goes in, a list of integers comes out, and its length is the token count you can act on before spending anything.

Why a token is not a word

For ordinary English prose, a token works out at roughly three-quarters of a word on average. That ratio is not a rule the tokeniser follows; it is a by-product of how the vocabulary was built. The vocabulary was trained by repeatedly merging the most frequent pairs of characters in a large body of mostly English text, so the sequences that appeared constantly — the, ing, people — ended up as single entries, while everything rarer has to be assembled from smaller pieces.

That explains the two cases where the ratio falls apart.

Code and structured data tokenise poorly because they are dense in characters that rarely form frequent pairs: brackets, underscores, colons, repeated indentation. A variable name like customer_lifetime_value is one word to you and several tokens to the model. A JSON payload can easily cost twice what the same information costs as a sentence.

Text outside the Latin alphabet tokenises worse still. Characters that appeared rarely in training are stored as raw bytes, so a single character can consume two or three tokens on its own. The newer o200k_base vocabulary is noticeably better here than the older cl100k_base, but the gap with English never closes completely.

The practical consequence: never budget by word count. If your input is code, tables, or a language other than English, measure it.

How three kinds of input tokenise differently A three-row comparison matrix showing plain English, code or markup, and non-Latin text, with how each is split into tokens and what that does to the cost of a request. Input type How it splits Effect on cost Plain English prose, chat, email Whole words common ones, at least Baseline about 3/4 of a word Code or JSON markup, tables, logs Symbol by symbol brackets, indents More tokens same word count Non-Latin text accents, kanji, emoji Byte by byte rare characters Most tokens measure, never guess
The three-quarters-of-a-word rule only holds for ordinary English; code and non-Latin text cost more tokens for the same amount of meaning, which is why you measure rather than estimate.

Step 3 — Count a whole messages list

Real requests are not bare strings. You send a list of messages, each a dictionary with a role and content, and the API wraps every one of them in invisible formatting before the model sees it. Those wrappers cost tokens too. If you only count the content values you will under-report every request, and the gap grows with the number of messages in a conversation.

The convention for the chat models is three tokens of overhead per message, one extra if a message carries an optional name field, and three more at the end because the reply is primed with the assistant role. Save this as tokens.py — the next step imports from it:

"""tokens.py — count the tokens in a chat request without sending it."""
import tiktoken

TOKENS_PER_MESSAGE = 3   # each message is wrapped in role/content framing
TOKENS_PER_NAME = 1      # optional "name" field costs one more
TOKENS_FOR_REPLY = 3     # the reply is primed with the assistant role


def encoding_for(model: str):
    try:
        return tiktoken.encoding_for_model(model)
    except KeyError:
        return tiktoken.get_encoding("o200k_base")


def count_message_tokens(messages: list[dict], model: str = "gpt-4o-mini") -> int:
    """Return the input token count for a list of chat messages."""
    enc = encoding_for(model)
    total = 0
    for message in messages:
        total += TOKENS_PER_MESSAGE
        for key, value in message.items():
            total += len(enc.encode(value))
            if key == "name":
                total += TOKENS_PER_NAME
    return total + TOKENS_FOR_REPLY


if __name__ == "__main__":
    demo = [
        {"role": "system", "content": "You are a concise assistant."},
        {"role": "user", "content": "Summarise these notes in three bullets."},
    ]
    naive = sum(len(encoding_for("gpt-4o-mini").encode(m["content"])) for m in demo)
    print("content only:", naive)
    print("with framing:", count_message_tokens(demo))

Run python tokens.py. The two numbers differ by around a dozen tokens on this tiny example — trivial on its own, but a chat that carries thirty turns of history pays that overhead thirty times, and it is exactly the sort of drift that makes a request bump into the model's limit when your own arithmetic said it would fit. If you have already hit that wall, Fix the Context-Length-Exceeded Error in Python explains the error itself; counting first is how you stop meeting it.

Note that the loop counts role as well as content, because the role string is real text in the request. Also note what it does not count: tool and function definitions, image inputs, and any extra parameters. For a text-only chat the number is close enough to budget against; the moment you attach tools, treat it as a floor rather than a total.

Step 4 — Guard a long input before you spend anything

Now put the count to work. The pattern is always the same three moves: build the messages, measure them, and only then decide. When the measurement is over your ceiling you either refuse loudly or trim deliberately — but you never send blind.

Pick a ceiling well below the model's real context limit. The limit covers your input and the reply, so leaving headroom is what stops a long answer from being cut off mid-sentence:

import os

import tiktoken
from dotenv import load_dotenv
from openai import OpenAI

from tokens import count_message_tokens

load_dotenv()  # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

MODEL = "gpt-4o-mini"
MAX_INPUT_TOKENS = 8000        # your ceiling, not the model's
enc = tiktoken.encoding_for_model(MODEL)


def build(document: str, question: str) -> list[dict]:
    return [
        {"role": "system", "content": "Answer only from the document."},
        {"role": "user", "content": f"{document}\n\nQuestion: {question}"},
    ]


def ask_about(document: str, question: str, allow_trim: bool = False) -> str:
    messages = build(document, question)
    used = count_message_tokens(messages, MODEL)

    if used > MAX_INPUT_TOKENS:
        if not allow_trim:
            raise ValueError(f"{used} tokens exceeds the {MAX_INPUT_TOKENS} limit")
        overflow = used - MAX_INPUT_TOKENS
        ids = enc.encode(document)
        document = enc.decode(ids[: max(0, len(ids) - overflow - 20)])
        messages = build(document, question)
        used = count_message_tokens(messages, MODEL)
        print(f"trimmed to {used} tokens")

    print(f"sending {used} input tokens")
    response = client.chat.completions.create(model=MODEL, messages=messages)
    print("billed input tokens:", response.usage.prompt_tokens)
    return response.choices[0].message.content


if __name__ == "__main__":
    notes = "The team agreed to ship on Friday. " * 400
    print(ask_about(notes, "What was decided?", allow_trim=True))

Two details are worth copying. The trim happens on token ids, not characters, so the result lands exactly on the budget instead of near it — slicing a string by character length is guesswork that can still overshoot. And the script prints response.usage.prompt_tokens next to your own figure, so you can see how close your local estimate was on a real call. For a text-only request like this one they should be within a handful of tokens.

Decision path for a request that may be too large A decision tree: count the assembled request, compare it against your own token ceiling, and either trim and re-count or send the request as it stands. Count the request messages plus framing Over your ceiling? a number you choose yes no Trim or refuse then count again Send it cost known upfront
Measure first, then branch: an oversized request gets trimmed and re-counted, and only a request you have already priced is allowed to leave your machine.

Trimming is the blunt option and it throws information away. When the document genuinely matters, split it instead and process the parts — Split Long Text into Chunks for AI APIs shows how to cut on sentence boundaries so each piece still reads sensibly.

Encoding quick reference

Use this to sanity-check what encoding_for_model gives you. You should still call the lookup rather than hard-coding a name, because the mapping changes as models are added.

EncodingModels it coversGet it directly with
o200k_basegpt-4o, gpt-4o-minitiktoken.get_encoding("o200k_base")
cl100k_baseGPT-4, GPT-3.5 Turbo, text-embedding-3-smalltiktoken.get_encoding("cl100k_base")
p50k_baseolder completion and code modelstiktoken.get_encoding("p50k_base")
r50k_basethe original GPT-3 completion modelstiktoken.get_encoding("r50k_base")

Anthropic's Claude models use a different tokeniser that tiktoken cannot reproduce. Counts from o200k_base are a rough sighting shot for Claude, nothing more; use the token-counting endpoint in Anthropic's own SDK when you need a real figure. The OpenAI vs Anthropic API for Beginners comparison covers the other differences between the two SDKs.

Troubleshooting

ModuleNotFoundError: No module named 'tiktoken' — the package is installed somewhere your interpreter is not looking, almost always because the virtual environment is not active. Activate it (source .venv/bin/activate, or .venv\Scripts\activate on Windows) and install again. Fix ModuleNotFoundError: No Module Named openai has the full diagnosis; it is the same fault with a different package name.

KeyError raised from encoding_for_model — you passed a model name the installed version of tiktoken does not know. Either upgrade the package (pip install -U tiktoken) or rely on the try/except KeyError fallback from step 1, which is why that wrapper exists.

ConnectionError on the very first encode calltiktoken fetches the vocabulary file once and caches it. Behind a proxy or on an offline machine the fetch fails. Run the script once with internet access to warm the cache, or set the TIKTOKEN_CACHE_DIR environment variable to a folder you have pre-populated on another machine.

Your count is lower than usage.prompt_tokens — you are sending something the formula does not model, usually tool definitions or an image. Compare the two numbers on a text-only call to confirm your maths is right, then add a fixed allowance for the extras and keep your ceiling conservative.

When to use this vs. alternatives

  • Counting locally vs. reading usage from the response. The usage object is authoritative but arrives after you have been billed. Use tiktoken to decide whether to send, and usage to reconcile what you actually spent — they answer different questions and belong in the same script.
  • Counting locally vs. dividing characters by four. The divide-by-four shortcut needs no dependency and is fine for a rough log line. It quietly under-counts code and badly under-counts non-Latin text, so it is unsafe as the guard that decides whether a request goes out.
  • Counting vs. capping max_tokens. Capping limits only the reply. A 60,000-token input still bills in full even if the answer is two words. Count the input; cap the output; do both.

Counting is the measurement layer everything else sits on. Once your script knows the size of a request, turn that number into money with Estimate OpenAI API Costs with Python, stop paying twice for identical prompts using Cache AI Responses in Python to Cut Costs, and put a hard ceiling on the month with Set a Monthly AI API Spending Limit. Back to Managing AI API Costs and Tokens.

Frequently asked questions

How do I count tokens in Python before calling the OpenAI API?

Install the tiktoken library, ask it for the encoding that matches your model, then call encode on your text and take the length of the list it returns. That number is the token count, and it costs nothing because the whole calculation happens on your own machine with no network request.

How many tokens is one word?

For ordinary English prose a token averages about three-quarters of a word, so 100 words lands near 130 tokens. The ratio is only a guide. Code, tables, unusual names and languages that do not use the Latin alphabet all break into more tokens per word, sometimes several times more.

Why does my tiktoken count differ from the usage field in the response?

Your count covers the message text and the fixed overhead the chat format adds. It does not cover tool definitions, image inputs, or provider-side additions. Treat your local number as a close estimate for text-only calls, and read usage.prompt_tokens from the response when you need the billed figure.

Which encoding should I use for gpt-4o-mini?

Use o200k_base, but do not hard-code it. Call tiktoken.encoding_for_model("gpt-4o-mini") and let the library pick, so your script keeps working when you switch models. Wrap that call in a try block and fall back to get_encoding("o200k_base") for model names the installed version does not recognise yet.

Does counting tokens locally cost money or need an API key?

No. tiktoken runs entirely offline once it has downloaded the vocabulary file for your encoding, and it never contacts the model provider. You can count a million prompts without an API key. Only the first run needs internet access, to fetch and cache that vocabulary file.