Tools,

Tesseract OCR on AWS Lambda: layer or container, tested

7.3 s

for 10 scanned pages at 1,769 MB

Get the Dockerfile

No package, and a layer hits glibc

The Lambda image for Python 3.13 runs Amazon Linux 2023. Its package manager has no Tesseract:

error: No package matches 'tesseract'

A layer with Debian's Tesseract is 50 MB, under the 250 MB limit. It still fails on the older glibc:

/opt/bin/tesseract: /lib64/libc.so.6: version `GLIBC_2.38' not found (required by /opt/bin/tesseract)

A container image skips both problems. It can hold any Linux, up to 10 GB.

A container image that works

Start from Debian, add Tesseract with apt, and add the AWS runtime interface client:

FROM python:3.13-slim-trixie
RUN apt-get update \
 && apt-get install -y --no-install-recommends tesseract-ocr poppler-utils \
 && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir awslambdaric boto3
ENV OMP_THREAD_LIMIT=1
WORKDIR /var/task
COPY app.py .
ENTRYPOINT ["python", "-m", "awslambdaric"]
CMD ["app.handler"]

450 MB image size

OMP_THREAD_LIMIT=1 keeps Tesseract on one thread. Two threads were slower at 1,769 MB: 8.8 s.

The handler: S3 in, text out

The PDF comes from S3, since Lambda caps a request at 6 MB. The text goes back as .txt:

import glob
import os
import subprocess
import tempfile
import urllib.parse

import boto3

s3 = boto3.client("s3")


def handler(event, context):
    rec = event["Records"][0]["s3"]
    bucket = rec["bucket"]["name"]
    key = urllib.parse.unquote_plus(rec["object"]["key"])
    with tempfile.TemporaryDirectory() as d:
        pdf = os.path.join(d, "in.pdf")
        s3.download_file(bucket, key, pdf)
        subprocess.run(["pdftoppm", "-r", "300", "-gray", pdf, d + "/p"], check=True)
        pages = sorted(glob.glob(d + "/p-*.pgm"))
        text = subprocess.run(
            ["tesseract", "stdin", "stdout", "--dpi", "300"],
            input="\n".join(pages),
            capture_output=True,
            text=True,
            check=True,
        ).stdout
    s3.put_object(Bucket=bucket, Key=key + ".txt", Body=text.encode())
    return {"key": key, "pages": len(pages), "words": len(text.split())}

Memory sets the speed

Lambda gives CPU in step with memory: one vCPU at 1,769 MB. I gave each test container the same share.

Memory 10 scanned pages, cold
128 MB 291 s
512 MB 36.7 s
1,024 MB 13.4 s
1,769 MB 7.3 s
3,008 MB 7.3 s

Every size read the same 1,879 of 1,955 true words.

Peak memory was 169 MB. One thread cannot use more than one vCPU, so 3,008 MB gained nothing.

Three errors and their fixes

Error Fix
(404) when calling the HeadObject operation S3 sends "March scan.pdf" as March+scan.pdf. Decode it with unquote_plus.
[Errno 30] Read-only file system Only /tmp can be written. Use tempfile, as above.
Task timed out after 3.00 seconds 3 s is the default. Raise it. The killed run also left its pages in /tmp.

Give the trigger a .pdf suffix filter. The handler writes .txt to the same bucket, and AWS warns that this can loop.

Or skip the image: call an OCR API

This function needs no layer and no image, only Python's urllib. It sends a PDF URL to our OCR Actor:

import json
import os
import urllib.request

API = "https://api.apify.com/v2/acts/brasspage~pdf-ocr-to-markdown/run-sync-get-dataset-items"


def handler(event, context):
    req = urllib.request.Request(
        API,
        data=json.dumps({"documentUrls": [event["url"]]}).encode(),
        headers={
            "Authorization": "Bearer " + os.environ["APIFY_TOKEN"],
            "Content-Type": "application/json",
        },
    )
    with urllib.request.urlopen(req, timeout=290) as r:
        item = json.load(r)[0]
    return {"pages": item["pagesConverted"], "markdown": item["markdown"]}

353 of 381 words, 2 scanned pages

26 MB peak, in a 128 MB function

Tesseract in the container found 355 on the same pages. Set the function timeout above the 290 s wait in the code.

How I tested it
  • No AWS account: each function ran in Docker under the AWS Lambda Runtime Interface Emulator 1.38, on an Apple Silicon Mac (arm64).
  • Lambda-like limits: a read-only root, /tmp on disk, a non-root user, memory with no swap, and CPU as memory over 1,769 MB. The CPU share is a model, not a Lambda measurement.
  • S3 was a local MinIO. Each run was a cold start in a fresh container, sent the event S3 sends for "March scan.pdf".
  • Scan: the 10-page PDF of OCR in n8n (proofread Wikisource scans, no text layer). Scored with the tessdata test scorer.
  • Debian trixie: Tesseract 5.5.0, Poppler 25.03.0, awslambdaric 4.2.3, boto3 1.44.0.
  • The Actor: one real run from the AWS base image for Python 3.13, at 128 MB.
What I did not test
  • A real AWS account: cold starts, the 6 MB cap and billing.
  • x86_64 functions. I built and ran arm64 only.
  • A layer built from source on Amazon Linux 2023.
  • Other languages, photos, and PDFs that already have text.
Sources

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

Can I install Tesseract with dnf on AWS Lambda?

No. The Lambda image for Python 3.13 runs Amazon Linux 2023, and dnf answers: No package matches 'tesseract'. Build a container image from Debian instead.

How much memory does Tesseract need on Lambda?

Peak use was 169 MB, but Lambda gives CPU in step with memory. 1,769 MB buys one full vCPU, and more did not help one thread.

Why does Lambda say Read-only file system?

Only /tmp can be written. Download the PDF and render the pages in a tempfile folder, which is under /tmp, as the handler on this page does.