Best OCR Model for Scanned Documents, Measured
Kenny Rogers ·


Turning scanned documents into reliable text is hard. Classic OCR engines such as Tesseract fail on faded print, handwriting and faxes. Vision models read those pages well, but sometimes write values that aren’t on the page. We made eight test pages from text we wrote, so every character could be checked, and ran them through 15 vision models and Mistral OCR on OpenRouter, plus Tesseract.
The best OCR model in our test was Claude Opus 5.5, with 262 of 264 key values right at $16.42 per 1,000 pages. Mistral OCR was the best value: 258 of 264 for $2.02. This post shows the results, the errors to watch for, and code to catch them.
These results are a starting point. Our eight pages aren’t your documents, so the only real way to know which model is best for your workflow is to test the candidates on your own scans.
Tl;dr
- Most accurate:
anthropic/claude-opus-5.5. Best value:mistral-ocr. Cheapest good result:inclusionai/ling-3.0-flash-vlwith reasoning off, 254 of 264 for $0.04 per 1,000 pages. - On a faded receipt, GPT-6 Luna and GPT-6.1 Sol made up card digits, wifi passwords and a server name. Error rates don’t show this, so check the values you’ll actually use.
- Turning reasoning off made four of five models we tested faster and cheaper, with no key values lost.
- Flagging the words where two cheap models from different labs disagreed caught 34 of 35 wrong words, for $0.62 per 1,000 pages.
How we tested
We rendered each page from known text, then degraded it like a scan: a small rotation, blur, noise and JPEG compression. Five pages are ordinary: a receipt, a letter with two deliberate misspellings, a form with a ticked box, a two-column newsletter and a neat handwritten note. Three are built to be hard: a faded thermal receipt, fast cramped handwriting, and a low-resolution fax of 12 serial numbers made from look-alike characters (0/O, 1/I/L, 5/S, 8/B), so a model can’t guess them from context.

We scored each transcript two ways. The character error rate (CER) is the edit distance to the true text divided by its length, ignoring line breaks and Markdown. The key values are 88 strings a pipeline would use, 10 to 15 per page, such as totals, dates, card digits, serials and the misspelled words. Each has to appear exactly in each of three runs, which gives 264 checks per model. Costs are the billed usage.cost of each request. Models are from the OpenRouter catalog as of October 8, 2026.
Which OCR model is most accurate on scanned documents?
The table is sorted by key values correct.
| Route | Key values correct | CER, all pages | CER, 3 hard pages | Cost per 1,000 pages | Median seconds |
|---|---|---|---|---|---|
anthropic/claude-opus-5.5 | 262 of 264 | 0.02% | 0.06% | $16.42 | 7.2 |
mistral-ocr (PDF engine) | 258 of 264 | 0.14% | 0.26% | $2.02 | 3.8 |
anthropic/claude-sonnet-5.5 | 256 of 264 | 1.34% | 3.49% | $7.68 | 4.2 |
google/gemini-3.8-flash | 254 of 264 | 0.15% | 0.28% | $7.50 | 9.3 |
stepfun/step-5-preview | 254 of 264 | 0.42% | 1.11% | $5.48 | 9.3 |
inclusionai/ling-3.0-flash-vl | 254 of 264 | 1.05% | 2.56% | $0.12 | 7.2 |
anthropic/claude-haiku-5.5 | 253 of 264 | 1.19% | 2.89% | $0.46 | 3.8 |
x-ai/grok-4.7 | 252 of 264 | 0.13% | 0.35% | $12.85 | 11.3 |
z-ai/glm-5.3-flashx | 252 of 264 | 0.33% | 0.86% | $1.45 | 5.5 |
openai/gpt-6.1-sol | 252 of 264 | 0.65% | 1.74% | $6.03 | 8.4 |
deepseek/deepseek-v4.1-flash | 248 of 264 | 0.20% | 0.48% | $0.20 | 4.8 |
qwen/qwen3.8-flash | 248 of 264 | 0.74% | 1.49% | $0.29 | 8.1 |
qwen/qwen3.8-27b | 246 of 264 | 0.92% | 2.31% | $1.54 | 10.8 |
xiaomi/mimo-v2.6-flash | 246 of 264 | 2.02% | 5.34% | $0.58 | 3.1 |
mistralai/mistral-large-4-0 | 240 of 264 | 4.61% | 12.19% | $6.80 | 47.9 |
openai/gpt-6-luna | 232 of 264 | 1.49% | 3.95% | $0.54 | 7.8 |
| Tesseract 5.5 (local, 1 run) | 46 of 88 | 26.06% | 64.39% | $0 | 58.1 |
On the five ordinary pages every vision model scored under 0.3% CER, and all 15 transcribed the neat handwriting perfectly. Most of the spread comes from the faded receipt. Opus 5.5 and Mistral OCR got all 33 checks on it, while GPT-6 Luna got 16. Models count image tokens differently (a median page was 1,070 prompt tokens for DeepSeek and 3,314 for Grok), so measure cost on your own pages.
Which OCR model fits your work
| If most of your work is | Start with | Why |
|---|---|---|
| Hard scans where every value matters | Claude Opus 5.5 | 262 of 264 key values, at $16.42 per 1,000 pages |
| High volume at a fair price | Mistral OCR (the mistral-ocr PDF engine) | 258 of 264 key values, at $2.02 per 1,000 pages |
| The lowest cost | Ling 3.0 Flash VL with reasoning off | 254 of 264 key values, at $0.04 per 1,000 pages |
| Serial numbers and IDs with look-alike characters | Claude Haiku 5.5 checked by DeepSeek V4.1 Flash | The pair flagged 34 of 35 wrong words, for $0.62 per 1,000 pages |
| Clean typed pages, offline | Tesseract 5.5 | Perfect on the letter and the newsletter, and free, but it returned nothing for the faded receipt |
What goes wrong when a vision model reads a hard scan?
The most serious error was a value that looks plausible but isn’t on the page. Here’s the bottom of the faded receipt as printed, followed by one GPT-6 Luna run:
CONTACTLESS 32.95
CARD ****9026
SERVED BY: PRIYA
WIFI: verdigris-guest / tide2026
NET 27.46
CONTACTLESS
CARD **** 9027
SERVED BY: SHEVA
WIFI: verdigris_guest / 1freecoffee
There’s no NET line on the receipt. The card ends in 9026, and the name and password are invented. GPT-6 Luna changed the server’s name and the year in all three runs. GPT-6.1 Sol wrote a different made-up password in each run (flatwhite24, flat9am!, flatleaf). We also asked models to write [?] instead of guessing. It didn’t help: four of seven models never used the marker.
The other errors that repeated:
- O and 0: Two fax serials were wrong in at least one run from 13 of the 15 models. Only Claude Sonnet 5.5 and GPT-6.1 Sol read them correctly every time.
- Spelling fixes: The letter is signed “Senior Assosiate”, and the prompt says to copy spelling exactly. 16 of 45 transcripts “corrected” it anyway.
- Ticked boxes: Claude Sonnet 5.5 and Haiku 5.5 copied the form’s checkbox labels but dropped which box was ticked.
Step 1: Send the scan to a vision model
A vision model generates its transcript the same way a language model generates any text, so it tends to write what text usually says rather than exactly what’s printed. The prompt asks it to copy exactly. That reduces the problem but doesn’t remove it: 16 of 45 transcripts still corrected the letter’s deliberate misspelling. You need an OpenRouter API key in OPENROUTER_API_KEY. The image goes in as a base64 image_url part (see image inputs), so every vision model on OpenRouter takes this same request. Save it as transcribe.py, since steps 3 and 4 import from it:
import base64
import mimetypes
import os
import requests
API = "https://openrouter.ai/api/v1/chat/completions"
HEADERS = {"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"}
PROMPT = ("Transcribe all of the text in this scanned document, in reading order. "
"Copy spelling, numbers and punctuation exactly as they appear, even if they "
"look wrong. Return plain text only, with no Markdown and no commentary.")
def transcribe(path, model="deepseek/deepseek-v4.1-flash", reasoning={"effort": "none"}):
mime = mimetypes.guess_type(path)[0] or "image/jpeg"
with open(path, "rb") as f:
image = f"data:{mime};base64," + base64.b64encode(f.read()).decode()
body = {
"model": model,
"max_tokens": 16000,
"messages": [{"role": "user", "content": [
{"type": "text", "text": PROMPT},
{"type": "image_url", "image_url": {"url": image}},
]}],
}
if reasoning:
body["reasoning"] = reasoning
response = requests.post(API, headers=HEADERS, json=body, timeout=300)
response.raise_for_status()
data = response.json()
choice = data["choices"][0]
if choice["finish_reason"] != "stop":
raise ValueError(f"finish_reason={choice['finish_reason']}: the transcript is incomplete")
return choice["message"]["content"], data["usage"]["cost"]
if __name__ == "__main__":
text, cost = transcribe("receipt.jpg")
print(text)
print(f"\n${cost:.5f}")
HARBOR LANE MARKET
1180 Harbor Lane, Unit 4
Portland OR 97209
Tel (503) 555-0148
...
TOTAL 62.86
VISA ****4471 62.86
AUTH 08813C
...
$0.00022
Every character came back right, for about two hundredths of a cent. The data URL takes its type from the file extension, so PNG and WebP scans work too. To compare models, change only the model string, and pass reasoning=None for the models in step 2 that can’t turn reasoning off.
The finish_reason check is there because reasoning tokens count against max_tokens. With a limit of 4,000, Gemini 3.8 Flash spent 3,841 tokens reasoning about the fax on every run and stopped partway through serial number 4. The request still returned 200 OK with finish_reason: "length", so without the check a pipeline stores short transcripts without noticing.
Step 2: Turn reasoning off for transcription
Reasoning helps when a model has to plan an answer. Copying text from a page doesn’t need a plan, so the extra tokens mostly add cost and time. That’s why transcribe sends "reasoning": {"effort": "none"} by default (see reasoning tokens). We reran the full test with reasoning off for every model that allows it:
| Model | Key values, on → off | CER, on → off | Cost per 1,000 pages, on → off | Median seconds, on → off |
|---|---|---|---|---|
mistralai/mistral-large-4-0 | 240 → 249 | 4.61% → 0.15% | $6.80 → $1.68 | 47.9 → 5.6 |
inclusionai/ling-3.0-flash-vl | 254 → 254 | 1.05% → 0.19% | $0.12 → $0.04 | 7.2 → 4.0 |
openai/gpt-6-luna | 232 → 232 | 1.49% → 1.08% | $0.54 → $0.22 | 7.8 → 3.8 |
deepseek/deepseek-v4.1-flash | 248 → 249 | 0.20% → 0.23% | $0.20 → $0.16 | 4.8 → 4.5 |
qwen/qwen3.8-27b | 246 → 244 | 0.92% → 2.28% | $1.54 → $0.77 | 10.8 → 8.9 |
With reasoning on, one Mistral Large 4 request reasoned for 626 seconds and returned no text (finish_reason: "error"). Qwen3.8 27B did better with reasoning on, so test your own pages. Gemini 3.8 Flash, Grok 4.7 and GLM 5.3 FlashX don’t allow reasoning to be turned off, so pass reasoning=None to leave the field out. Otherwise the request fails with:
400 {"error":{"message":"Reasoning is mandatory for this endpoint and cannot be disabled.","code":400,"metadata":{"provider_name":null}}}
Step 3: Cross-check two models from different labs
Look-alike characters such as O and 0 are hard to tell apart in a blurry image. Each model decides based on its own training, so two models from different labs rarely make the same wrong choice. Where their transcripts differ is usually where the image is unclear. We tested every pair among six models. The best was Claude Haiku 5.5 checked by DeepSeek V4.1 Flash. Haiku made 35 word errors in 2,964 words, and DeepSeek, with reasoning off as in step 2, disagreed on 34 of them, with 10 false alarms. The pair costs $0.62 per 1,000 pages, a third of Mistral OCR. Both use the same API, so the check is one extra call. Haiku is called with reasoning=None because that’s how it ran in our table:
import difflib
from transcribe import transcribe # the function from step 1
a, _ = transcribe("fax.jpg", model="anthropic/claude-haiku-5.5", reasoning=None)
b, _ = transcribe("fax.jpg", model="deepseek/deepseek-v4.1-flash")
a, b = a.split(), b.split()
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
if tag != "equal":
print("check:", " ".join(a[i1:i2]), "vs", " ".join(b[j1:j2]))
check: 8LI5-QS0B5O vs 8LI5-QS0B50
check: 30HY-1OHLG7 vs 30HY-10HLG7
Those are the two serials most models got wrong. If both models misread a character the same way, nothing is flagged. That happened once in our runs, with an O both read as a zero, so validate IDs against a list or a checksum where you can. The comparison also assumes both models read the page in the same order. In one of three runs the models listed the fax’s two columns in different orders, and every serial showed up as a disagreement.
Step 4: Use Mistral OCR as a dedicated engine
Mistral OCR is a model trained only for document OCR. On OpenRouter it runs as an engine in the file-parser plugin, which accepts PDFs, at $2 per 1,000 pages. The plugin OCRs the file before your model sees it. It returns the text as a file annotation on the response, so the model you name only has to reply. Wrap the scan in a one-page PDF and send it with any cheap model:
import base64
import html
import io
import os
import requests
from PIL import Image, ImageSequence
from transcribe import API, HEADERS # from step 1
def mistral_ocr(path):
if path.lower().endswith(".pdf"): # scanned PDFs go as they are
with open(path, "rb") as f:
raw = f.read()
else: # wrap an image in a PDF in memory, one page per frame of a multipage TIFF
pages = [p.convert("RGB") for p in ImageSequence.Iterator(Image.open(path))]
buf = io.BytesIO()
pages[0].save(buf, "PDF", save_all=True, append_images=pages[1:])
raw = buf.getvalue()
pdf = "data:application/pdf;base64," + base64.b64encode(raw).decode()
pdf_name = os.path.splitext(os.path.basename(path))[0] + ".pdf"
body = {
"model": "upstage/solar-mini4", # any cheap text model; it only has to reply
"max_tokens": 5,
"messages": [{"role": "user", "content": [
{"type": "text", "text": "Reply OK."},
{"type": "file", "file": {"filename": pdf_name, "file_data": pdf}},
]}],
"plugins": [{"id": "file-parser", "pdf": {"engine": "mistral-ocr"}}],
}
response = requests.post(API, headers=HEADERS, json=body, timeout=300)
response.raise_for_status()
data = response.json()
parts = data["choices"][0]["message"]["annotations"][0]["file"]["content"]
texts = [p["text"] for p in parts if p.get("type") == "text"] # skip extracted image parts
text = "\n".join(t for t in texts if not t.startswith(("<file", "</file")))
return html.unescape(text), data["usage"]["cost"]
text, cost = mistral_ocr("receipt_faded.jpg")
print(text)
print(f"\n${cost:.5f}")
CAFE VERDIGRIS
22 Quayside Row
Bristol BS1 4QA
VAT No. GB 318 7742 09
27/09/2026 08:17 TILL 2 CHK 0193
...
| CONTACTLESS | 32.95 |
| CARD ***9026 | |
SERVED BY: PRIYA
WIFI: verdigris-guest / tide2026
$0.00202
Every value on the faded receipt came back right, including the card digits and password. Mistral OCR is built to keep layout, so its output is Markdown: receipt lines become tables, headings get ##, and & comes back as &. That’s why the function unescapes the HTML entities. Strip the Markdown if you need plain text, or keep it if an LLM reads the result next.
Is an LLM better than Tesseract for OCR?
Yes, except on clean typed pages. Tesseract 5.5 reads character shapes line by line, so it holds up on crisp print and breaks down on noise and handwriting. It transcribed the letter and the newsletter perfectly. It returned nothing for the faded receipt, 1 of 11 values on the messy handwriting and 4 of 15 on the fax.
Frequently asked questions
Which LLM is best for OCR?
Claude Opus 5.5 in our test. Gemini 3.8 Flash and Grok 4.7 had error rates almost as low but missed more serials. DeepSeek V4.1 Flash and Ling 3.0 Flash VL came close for under $0.20 per 1,000 pages.
How do I OCR a scanned PDF?
Send it with the mistral-ocr engine as in step 4. Alternatively, render each page to an image for a vision model, or send the PDF directly to a model that reads PDFs. A free text parser won’t work on a scan, because a scan has no text layer.
What is the most accurate OCR model?
Claude Opus 5.5 got 262 of 264 key values right and had a 0.02% character error rate across our eight scans. Mistral OCR was second with 258 of 264, at an eighth of the cost.
How much does OCR with an LLM cost?
In our test it ran from $0.04 per 1,000 pages (Ling 3.0 Flash VL with reasoning off) to $16.42 (Claude Opus 5.5). Mistral OCR costs $2 per 1,000 pages. Models count image tokens differently, so measure cost on your own pages.
Is Mistral OCR better than a vision model?
It was the best value in our test: 258 of 264 key values for $2.02 per 1,000 pages, behind only Claude Opus 5.5. It returns Markdown that keeps the layout, while a vision model returns whatever format you ask for.
Can an LLM read handwriting?
Yes. All 15 vision models transcribed our neat note perfectly. On messy handwriting most stayed above 99% character accuracy, and the misses were mostly names (“Ackterberg” for “Achterberg”).
Next steps
- Image inputs covers sending images by URL or base64, and our vision API guide covers detail levels.
- PDF inputs lists the PDF engines and explains how to reuse parsed results.
- The vision models collection lists every model that takes images.
- To turn a transcript into typed fields, see structured outputs.