Tools,

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.

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

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.

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

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.