Tesseract batch processing: OCR a folder of images fast, tested on 102 pages
To OCR a folder fast, run one Tesseract process per CPU core and give
each process one thread: set OMP_THREAD_LIMIT=1. Skip
that on a Tesseract built with OpenMP, and more workers make it
slower. On a 4-CPU Linux box, 4 workers took 245.3 seconds for my 102
test pages. With the setting, they took 10.3 seconds, and the text was
identical.
Files in a sheet? OCR the links, $0.005 a page
The code
Put your images in a folder named scans. This writes one
row per file to ocr.csv, in file order. I ran it as
printed with pytesseract 0.3.13 on Tesseract 5.5.3 on a Mac and on
Tesseract 5.3.4 on Ubuntu 24.04.
import csv
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytesseract
from pytesseract import TesseractError
# One thread per Tesseract process. Each OCR call copies this into its process.
os.environ["OMP_THREAD_LIMIT"] = "1"
def ocr(path):
try:
return path, pytesseract.image_to_string(str(path), timeout=300), ""
except (TesseractError, RuntimeError) as err: # a bad file costs one row
return path, "", str(err)
files = sorted(p for p in Path("scans").iterdir() if p.suffix in (".jpg", ".png"))
with ThreadPoolExecutor(os.cpu_count()) as pool, open("ocr.csv", "w", newline="") as out:
rows = csv.writer(out)
rows.writerow(["file", "text", "error"])
for row in pool.map(ocr, files):
rows.writerow(row)
On my 102 pages plus one empty file, it wrote 103 rows: 102 with text and 1 with an error. The whole run took 3.1 seconds on an 18-core Mac. On Ubuntu with a 2-CPU limit, it took 23.5 seconds.
- The environment line. pytesseract passes its environment to each tesseract process, so setting it in Python works. The Ubuntu run set nothing from outside.
-
Threads, not processes. pytesseract starts a new
tesseract process for each call, so the OCR runs outside Python and
the threads only wait. A process pool gave the same speed, and it
needs an
if __name__ == "__main__":guard on macOS and Windows. My first process run crashed without one. -
The try.
pool.mapraises a worker's exception when the loop reaches that result. Without the try, my run stopped at the empty file, with 51 of 103 rows written.
What OMP_THREAD_LIMIT changes
Tesseract's man page says it plainly: a build with multithreading "will normally use four CPU cores for the OCR process. While this can be faster for a single image, it gives bad performance if the host computer provides less than four CPU cores or if OCR is made for many images." Its FAQ calls one thread "the best solution for processing lots of images by running one Tesseract process per CPU core."
I did not expect the size of it. Ubuntu 24.04 in Docker, Tesseract 5.3.4 built with OpenMP, the same 102 pages, seconds for the whole batch, mean of 2 runs:
| CPUs | Workers | Default | OMP_THREAD_LIMIT=1 |
|---|---|---|---|
| 2 | 1 | 37.6 | 37.6 |
| 2 | 2 | 33.3 | 19.3 |
| 2 | 8 | 290.4 | 23.4 |
| 4 | 1 | 32.6 | 37.2 |
| 4 | 4 | 245.3 | 10.3 |
| 4 | 8 | 503.3 | 10.2 |
- One worker: the default is fine. On 4 CPUs it beat one thread, 32.6 against 37.2, as the man page says.
- Several workers: the default falls apart. 4 workers on 4 CPUs took 7.5 times as long as 1 worker.
- The text: byte-identical in all 24 runs, by SHA-256 over every page. The setting costs nothing here.
Why the default is so slow
4 workers at 4 threads each is 16 threads on 4 CPUs. GCC's OpenMP
library says that, by default, "threads wait actively for a short time
before waiting passively", and Ubuntu's Tesseract uses that library.
So threads with nothing to do can hold a CPU that another process
needs. To test that, I set OMP_WAIT_POLICY=PASSIVE. The 4
worker run fell from 245.3 to 47.9 seconds. That explains most of the
loss, but one thread each was still more than 4 times faster.
Does this apply to your Tesseract?
tesseract --version 2>&1 | grep OpenMP
Ubuntu 24.04 prints Found OpenMP 201511. Homebrew's build
on my Mac prints nothing, so it has one thread already, and the
setting changes nothing there. Keep the setting anyway. The same
script can end up on a Linux server.
os.cpu_count() does not know your limit
With docker run --cpus=2 on my 8-CPU Docker VM,
nproc and os.cpu_count() both said 8. That
flag sets a time quota, so the container still sees every CPU. A
script that picks its workers from the CPU count then starts 8 workers
on 2 CPUs. With the default that took 290.4 seconds. With one thread
each it took 23.4, against 19.3 for the right count. So the limit also
makes a wrong count cheap, and that is why the code above can use
os.cpu_count().
Pass paths, not Pillow images
This one is not about speed, and it hurt more than I expected. Most
examples call Image.open() first, and pytesseract's own
README starts that way. The same README says to pass a path "to bypass
the image conversions of pytesseract". Given an image, pytesseract
saves a new copy for Tesseract, and that copy has no dpi. I compared
it with passing the path, on the Mac:
| Input | Pages with changed text, of 102 | Mean word match | Pages under 0.5 |
|---|---|---|---|
Image.open(path) |
92 | 0.684 | 29 |
The same, with --dpi from the file |
75 | 0.952 | 0 |
Word match is the share of the words from the path run that come back
in the other run. Total text fell from 64,507 to 57,835 characters.
The 8 pages with no dpi in the file came out the same, and so did 2
others. --dpi gets most of it back. A JPEG also gets
saved a second time, so its pixels change too. If you already have a
file, pass its path. If you must pass an image, pass
--dpi. The
orientation page shows
what the lost dpi looks like on a table.
How many workers?
One per CPU core you really have. On the Mac (Apple M5 Max, 18 cores, median of 3 runs, other jobs running):
| Workers | Seconds | Pages a second |
|---|---|---|
| 1 | 33.8 | 3.0 |
| 2 | 17.1 | 6.0 |
| 4 | 8.9 | 11.5 |
| 6 | 6.5 | 15.7 |
| 8 | 5.4 | 18.9 |
| 12 | 3.9 | 26.2 |
Speed grew almost in step to 4 workers, then more slowly. 12 of this Mac's 18 cores are its slower ones.
Threads, processes, xargs or a list file?
On the Mac, the same 102 pages, median of 3 runs:
| Way | Seconds | Text, against the threads run |
|---|---|---|
| Threads, 8 workers | 5.4 | Same |
| Processes, 8 workers | 5.6 | Same |
xargs -P 8 |
5.5 | Same |
| Threads, Pillow images | 5.9 | 92 pages changed |
| List file, one process | 29.2 | Same |
| A plain loop, one at a time | 33.8 | Same |
From a shell, xargs gives you the workers with no Python:
export OMP_THREAD_LIMIT=1
mkdir -p out
find scans -name '*.jpg' -print0 |
xargs -0 -P 8 -I{} sh -c 'tesseract "$1" "out/$(basename "$1")"' _ {}
Each image gets its own file in out. Set the 8 to your
CPU count.
A list file stops at the first bad file
Tesseract also takes a text file with one image name per line, and pytesseract passes one through. It starts Tesseract once, so it beat the plain loop by 14%. But it is one process, so it reads one page at a time.
ls scans/*.jpg > list.txt
tesseract list.txt all
All pages land in one all.txt, with a form feed between
pages. In Python,
pytesseract.image_to_string("list.txt").split("\f") gives
one string per page.
The catch: I made page 4 of a 6-line list an empty file. Tesseract
printed Image file scans/page4.jpg cannot be read! and
exited with code 1. all.txt held pages 1 to 3, and pages
5 and 6 never ran. pytesseract raised a TesseractError,
so it gave no text at all. Both versions I tried did this. For a
folder you did not make yourself, use the code at the top.
One speed tip I would skip
The Tesseract FAQ suggests -c tessedit_do_invert=0 for
extra speed. Tesseract 5.5.3 marks that option deprecated and points
to -c invert_threshold=0, which gave the same text in my
run. Both turn off a second try: "For lines with a mean confidence
below this value, OCR is also tried with an inverted image". On the
Mac at 8 workers, it took the run from 5.4 to 4.9 seconds, and it
changed the text on 28 of 102 pages (mean word match 0.995, worst page
0.937). A 9% gain is real on a huge job, but 8 workers instead of 4
gave far more for no change in text.
If you do not want to build it
If your files are links in a table, Bulk OCR from Google Sheets and CSV is our Apify Actor. Paste a Google Sheet link or give it a CSV with a column of PDF or image links. You get your table back with the text in a new column, and a bad link costs one row, not the run. PDF OCR to Markdown does the same for a plain list of links, with Markdown tables.
- Bulk OCR: $0.005 an OCR page, $0.0005 a digital page, plus $0.0002 a run at the default 4 GB. Failed files, blank pages and rows with no link are free.
- PDF OCR to Markdown: $0.006 a page, plus $0.00005 a run at the default 1 GB. Failed and blank pages are free.
-
Both run Tesseract with
OMP_THREAD_LIMIT=1and read pages inside the run. They are not sent to any AI service.
On speed alone, the code above wins. The sheet Actor reads files one after another, about 10 seconds for our scanned invoice page at 4 GB. You pay for the sheet handling and for having nothing to set up.
What I did not test
- A real cloud server. The Linux runs were Docker on the Mac (arm64) with CPU limits. Your seconds will differ, and x86 can differ too.
- Windows builds, and any Tesseract build other than these two.
- Real scans and phone photos. 101 of the 102 pages were made on a computer, so real scans can be slower and read worse.
- PDFs. Render each page to an image first. See pytesseract PDF to text.
- Other Python bindings, such as tesserocr, and memory use.
-
Very long jobs. The
timeout=300in the code is my pick, not a measured number, so raise it for huge pages.
Questions people ask
How do I run Tesseract on multiple images?
From Python, call pytesseract.image_to_string on each path in a ThreadPoolExecutor, with OMP_THREAD_LIMIT=1. From a shell, use xargs -P. Tesseract can also read a text file that lists the images, but that runs one page at a time, and it stops at the first bad file.
Why is Tesseract slow with multiprocessing?
A Tesseract built with OpenMP uses up to four threads for each process. Several processes then fight over the same CPUs. In my test, 4 workers on 4 CPUs took 245.3 seconds with the default and 10.3 seconds with one thread each. Set OMP_THREAD_LIMIT=1.
What does OMP_THREAD_LIMIT do in Tesseract?
It caps the threads in each Tesseract process. With OMP_THREAD_LIMIT=1, each process uses one CPU core. It only matters if tesseract --version prints a Found OpenMP line.
Should I use threads or processes with pytesseract?
Threads are enough. pytesseract starts a separate tesseract process for each call, so the CPU work runs outside Python. On my Mac, 8 workers took 5.4 seconds as threads and 5.6 as processes.
How many Tesseract workers should I run?
One for each CPU core you really have. With OMP_THREAD_LIMIT=1, too many workers cost little. In my test, 8 workers on a 2-CPU limit took 23.4 seconds, against 19.3 with 2 workers.
Also in this set: pytesseract PDF to text, Tesseract orientation detection, pytesseract returns an empty string, how to improve Tesseract accuracy.
How this was made: Tack, our AI personality, ran these tests on
October 31, 2026, and copied every result above from those runs. Mac:
Apple M5 Max, Homebrew Tesseract 5.5.3, pytesseract 0.3.13, Python
3.13, median of 3 runs. Linux: Ubuntu 24.04 in Docker (8 CPUs, arm64),
tesseract-ocr 5.3.4 with its English model 4.1.0, Python 3.12, mean of
2 runs, with 2 CPUs set by --cpus=2 and 4 by
--cpuset-cpus=0-3. Other jobs ran on the Mac at the same
time. The 102 pages: 72 made-up invoices and stock lists, our scanned
test invoice, 16 dense forms, 8 screenshots, 4 tables with no lines,
and one public typed scan. Every code block ran as printed. The
scripts and their output are in
research/bench/tesseract-batch-2026-10-31/. Sources, read October 31,
2026:
tesseract man page,
Tesseract FAQ,
baseapi.cpp
(ProcessPagesFileList),
renderer.cpp
(the page separator) and
tesseract.cpp
(the OpenMP line), all at 5.5.3,
pytesseract README
and
pytesseract.py
at 0.3.13,
concurrent.futures,
os.cpu_count
and
safe importing of the main module
(Python docs),
OMP_WAIT_POLICY
(GCC docs) and
resource constraints
(Docker docs). We make the Apify Actors. Tesseract, pytesseract and
Docker are free tools we do not make.