A Tesseract OCR API in 53 lines, no key needed
55 of 55
pages matched the tesseract command, on a Mac
There is no Tesseract API key
The project runs no hosted service, so wrap the command in a small server.
The server: 53 lines of Python
Standard library only: no Flask, no pytesseract. The image goes to Tesseract on stdin, and the text comes back. No temp files, no shell.
run = subprocess.run(["tesseract", "stdin", "stdout", "-l", lang],
input=page, capture_output=True, timeout=120)
server.py, all 53 lines
# A small Tesseract OCR REST API: POST a page image, get its text back.
# Run: python3 server.py
# Try: curl --data-binary @page.png "localhost:8884/?lang=eng"
import hmac
import os
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
TOKEN = os.environ.get("OCR_TOKEN", "") # set one before it goes online
MAX_BYTES = 20 * 1024 * 1024
listed = subprocess.run(["tesseract", "--list-langs"],
capture_output=True, text=True).stdout
LANGS = set(listed.split("\n")[1:]) - {""}
class OCR(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # so curl gets "100 Continue" at once
def do_POST(self):
size = self.headers.get("Content-Length", "")
if not size.isdigit() or not 0 < int(size) <= MAX_BYTES:
self.close_connection = True # the body was not read
return self.reply(413, "Send one image of up to 20 MB\n")
page = self.rfile.read(int(size))
auth = self.headers.get("Authorization", "")
if TOKEN and not hmac.compare_digest(auth, f"Bearer {TOKEN}"):
return self.reply(401, "Send Authorization: Bearer <token>\n")
query = parse_qs(urlparse(self.path).query)
lang = query.get("lang", ["eng"])[0].replace(" ", "+") # a + in a URL means space
if not all(code in LANGS for code in lang.split("+")):
return self.reply(400, f"Languages: {' '.join(sorted(LANGS))}\n")
try:
run = subprocess.run(["tesseract", "stdin", "stdout", "-l", lang],
input=page, capture_output=True, timeout=120)
except subprocess.TimeoutExpired:
return self.reply(504, "Took over 120 seconds\n")
if run.returncode:
return self.reply(422, "Tesseract could not read that file\n")
self.reply(200, run.stdout.decode())
def reply(self, code, text):
body = text.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8884"))
ThreadingHTTPServer((host, port), OCR).serve_forever()
Run it, then send a page:
python3 server.py
curl --data-binary @page.png "localhost:8884/?lang=eng"
Nothing lost over HTTP
| Run | Words found | Pages a minute |
|---|---|---|
| tesseract command | 14,167 | 65.0 |
| server.py on the Mac | 14,167 | 56.8 |
| server.py in Docker | 14,166 | 53.7 |
On the Mac, every answer matched the command byte for byte. Docker runs Debian's older build of Tesseract. Only 12 pages matched exactly, and it found 1 word fewer in all.
Docker: 7 lines, 339 MB
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends python3 tesseract-ocr && rm -rf /var/lib/apt/lists/*
COPY server.py /app/server.py
ENV HOST=0.0.0.0 OMP_THREAD_LIMIT=1
USER nobody
EXPOSE 8884
CMD ["python3", "/app/server.py"]
docker build -t tesseract-api .
docker run -p 127.0.0.1:8884:8884 -e OCR_TOKEN=pick-one tesseract-api
curl -H "Authorization: Bearer pick-one" --data-binary @page.png "localhost:8884/?lang=eng"
-p 127.0.0.1 keeps the port on this machine. With
OCR_TOKEN set, a request without the token gets a 401.
One line: 209 pages a minute, not 24
That line is OMP_THREAD_LIMIT=1. Debian's Tesseract is
built with OpenMP, so each request starts its own threads. With 4
requests at once, that cost 89% of the speed.
| OMP_THREAD_LIMIT | 1 at a time | 4 at a time |
|---|---|---|
| 1 (the Dockerfile) | 53.7 | 209.2 |
| Not set | 57.1 | 23.9 |
What it says to a bad request
| Request | Answer |
|---|---|
| No token, or the wrong one | 401 |
| Empty, or over 20 MB | 413 |
Language xyz or eng;ls |
400 |
| A PDF, or not an image | 422 |
| A GET | 501 |
?lang=eng+osd works as typed. A plus in a URL means a
space, so the server puts it back. My first draft answered 400, and my
own test had missed it.
There is no limit on requests at once. Keep it private, or add one.
Or call ours: it reads PDFs too
| What | server.py | Our Actor |
|---|---|---|
| PDF files | No, 422 | Yes |
| Output | Plain text | Markdown with tables |
| You run | A server | Nothing |
curl -X POST "https://api.apify.com/v2/acts/brasspage~pdf-ocr-to-markdown/run-sync-get-dataset-items" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"documentUrls": ["https://example.com/scan.pdf"]}'
It is slower for one page: 5.8 seconds against 0.12 on server.py, on Tesseract's own test page. Both returned 60 words.
Each run adds a $0.00005 start fee. Blank pages and failed pages are free.
Questions people ask
Does Tesseract need an API key?
No. Tesseract is free software that you run, under the Apache 2.0 license. Only a hosted service, like ours, asks for a key.
Where are the Tesseract API docs?
The C and C++ API is in libtesseract. Start with the API examples page in the Tesseract docs, then the headers capi.h and baseapi.h.
Can this server read a PDF?
No. Tesseract does not read PDF files, so the server answers 422. Turn each page into an image first, or send the PDF to our Actor.
Method, limits and sources
How I tested it
- Pages: 55 printed pages from 11 public domain books on Wikisource, as JPEGs 1,920 px wide. I left out one hand-lettered book.
- Truth: each page's proofread text. A word counts if the output holds it, in any order.
- "Matched" means the SHA-256 of the output was the same.
- Speed: one run each on an Apple M5 Max with other jobs running, so read it as rough.
- The server and Docker commands ran as printed, with a test token.
What I did not test
- Ready-made Tesseract server images from Docker Hub.
- Windows, servers on the open internet, and the 120 second timeout.
- Phone photos, handwriting, and languages other than English.
- Hosted OCR APIs other than ours.
Versions and sources
- Mac: Tesseract 5.5.3 from Homebrew.
- Docker 29.8.2: Debian 13, Tesseract 5.5.0, Python 3.13.5.
- Tesseract README, for the library and the command.
-
Tesseract FAQ, for
OMP_THREAD_LIMIT.
Two ways to skip the work
Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.
Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.