How to improve Tesseract accuracy: what each fix did on two real scans
On my scanned test invoice, two fixes did most of the work. Telling Tesseract the resolution took it from 0 to 24 of 36 numbers right. Reading the table one cell at a time, with a check on the math, took it to 33 and flagged the other 3. Cleaning the image to black and white first did not help, and on a clean typed page it made the result a little worse.
What each step did
Two test pages, both 300 dpi scans. One is page 1 of Executive Order 9066, typed in 1942, a public domain scan. The other is a plumbing invoice we made, with 36 numbers in a ruled table, tilted 0.8 degrees, with noise, blur and JPEG grain. We made the invoice, so the true value of every number is known.
| Step | Typed page: word match | Invoice: numbers right |
|---|---|---|
| pytesseract, no dpi given | 0.957 | 0 of 36 |
Add --dpi 300 (psm 3) |
0.957 | 24 of 36 |
| Same, psm 4 | 0.957 | 21 of 36 |
| Same, psm 6 | 0.957 | 0 of 36 |
| Deskew, denoise and black and white first (psm 3) | 0.939 | 22 of 36 |
| Join words split at line ends (psm 3) | 0.972 | not run |
| Read table cells one by one and check the math (our Actor's full pass) | 0.985 | 33 of 36, all 3 misses flagged |
Word match is the share of words that line up, in order, with an earlier transcript of the same page. 1.000 would mean identical. Our OCR for RAG page counts the same page another way, against a transcript checked word by word: plain Tesseract got 187 of 194 words, our Actor 191.
1. Tell Tesseract the resolution
This one surprised me. The tesseract command read 21 of
the invoice's 36 numbers straight from the JPEG at psm 4. The same
call through pytesseract, the Python wrapper, read 0. Its output began
like this:
01 [american fo [usar [esa
toe [Pex ung [7 [mat [sso
pytesseract hands Tesseract a temporary copy of the image, and that copy did not keep the file's 300 dpi. So Tesseract had to guess the size of the text, and it guessed wrong. One setting fixed it:
import pytesseract
from PIL import Image
img = Image.open("invoice.jpg")
text = pytesseract.image_to_string(img, config="--psm 3 --dpi 300")
Use the real resolution of your scan. On the typed page, the missing dpi changed nothing, so a test on one easy page can hide this.
2. Try psm 3, 4 and 6 on your own pages
The page segmentation mode (psm) tells Tesseract what layout to expect. On the typed page, all three modes gave the same result. On the invoice they did not. psm 3 (find the layout itself) got 24 numbers, psm 4 (one column of lines) got 21, and psm 6 (one block of text) got 0, with rows like "it [comprise fe [visas ose".
That is one table. On another layout the order can change, so try the three modes on a few of your own pages before you pick one.
3. Clean the image only if it helps
Most tips say to straighten the page, remove noise and make it black and white before OCR. I did all three: deskew, a 3 pixel median filter, then an Otsu threshold. On the invoice, the count went from 24 to 22 at psm 3 (and from 21 to 22 at psm 4). On the typed page it went down, from 0.957 to 0.939.
Tesseract already makes its own black and white copy of each page. So measure each step on your own pages before you keep it.
4. Fix the text after OCR
Tesseract keeps every line break of the paper, and it splits words at line ends, such as "util- ities". The typed page had 3 of them. One line of Python joins them, and the word match went from 0.957 to 0.972:
import re
text = re.sub(r"-\n(\w)", r"\1", text)
It also joins a real hyphen that falls at a line end, so "well-known" split over two lines becomes "wellknown". Our Actor's full pass, which also joins wrapped lines into paragraphs, scored 0.985.
5. Read tables one cell at a time, then check the math
This was the big one for the invoice: from 24 to 33 numbers. Our Actor finds the table by its ruling lines, cuts out each cell, and reads it alone with psm 7 (one line of text). When a number cell looks like it lost its decimal point, it reads that cell again with digits only. Then it checks every row: Qty times Unit price must equal Amount, and each column must keep its decimal places.
The 3 numbers it still got wrong, and the warning for each, word for word (row 1 is the header):
550.48 read as 350.48
Table row 3: Qty times Unit price does not equal Amount. Check these numbers.
Qty 1 read as nothing
Table cell row 6, column 3 has marks that could not be read.
1,138.00 read as 138.00
Table row 13: Qty times Unit price does not equal Amount. Check these numbers.
The same invoice as a PDF, run on Apify, got 32 of 36, and all 4 misses had a warning (more on that run in PDF table to Markdown). Words get no such check: "Shutoff valve" came back as "ShutolT valve" with no warning.
What I did not test
- Handwriting, phone photos and very small print. Tesseract is weak on all three.
- Training Tesseract on your own font.
- The model files, tessdata_best against tessdata_fast. My Mac runs used the Homebrew eng model. The Actor uses tessdata_fast.
- Other tables. On a screenshot of a table with no lines, plain Tesseract read every number right, and our Actor read 3.25 as "aro". The cell pass helps ruled tables, not every page.
For many files, or from your code
The last row of the table is
PDF OCR to Markdown, our Apify Actor. It reads a PDF's own text where there is some, and
uses OCR only on scanned pages. Paste PDF or image links, or upload a
file. You get Markdown for the whole file, a pages list
with each page's warnings, and a .md file.
- $0.006 a page, plus $0.00005 a run at the default 1 GB of memory. Pages that fail are free.
- The Apify free plan lets you try 20 pages a run and 50 a month.
- Pages are read inside the run. They are not sent to any AI service.
Want the free route instead? See how to turn a scanned PDF into Markdown, with OCRmyPDF and Docling. Screenshots, not scans? See image to Markdown.
How this was made: Tack, our AI personality, ran Tesseract 5.5.3 (Homebrew) through pytesseract on a Mac on October 29, 2026, one run per step, and copied the counts above from that run. The Apify run used PDF OCR to Markdown build 0.1.18 the same day. The typed page is a public domain scan from Wikimedia Commons, and we made the test invoice. We make the Apify Actor. Tesseract and pytesseract are free, open source tools that we do not make.