Paperless-ngx OCR speed: 100 scanned pages on 4 vCPU
Tested on Paperless-ngx 3.4.1
Test result
On 4 vCPU Paperless-ngx 3.4.1 consumed 100 image-only pages (10 documents) in 272 s, about 2.7 s per page, with all containers peaking at 1,022 MiB. Against the text drawn on the pages the OCR had a median word error rate of 0.08 %, and 10 of 10 exact-phrase searches found the right document, but the pages were clean, generated renderings and real scans will do worse.

OCR is where a document server spends its CPU, so this test fed Paperless-ngx 100 pages with no text layer and measured how long consumption took, how much memory it used, and how much of the text came back correctly.
The test documents
Ten public-domain books from Project Gutenberg (A Christmas Carol, Pride and Prejudice, Frankenstein, Moby Dick, A Tale of Two Cities, The Adventures of Sherlock Holmes, Tom Sawyer, Dracula, Treasure Island, The Picture of Dorian Gray) each contributed 10 pages, 47,139 words in total. gen.py drew each page in DejaVu Serif at 200 dpi on A4, then rotated it by less than 0.6 degrees, blurred it, added noise and a vignette, and saved JPEGs inside PDFs. The 10 PDFs total 58.6 MB and contain images only. The exact text drawn on each page is saved next to the OCR output, so accuracy has a ground truth.
These pages are generated, labeled as such, and easier than most real material: the font is clean and regular, there are no stains, folds, handwriting, tables, stamps or colored backgrounds, and the language is English, which the default OCR setup handles. Treat the speed as a reference for 200 dpi text pages of about 470 words, and the accuracy as an upper bound.

Speed and memory
All 10 PDFs were sent through POST /api/documents/post_document/ in about 1 s, so they queued the way a batch scan would, and the test polled the task API until each finished. The stack’s default settings were used; nproc in the container showed 4.
| Measurement | Result |
|---|---|
| Documents / pages / words | 10 / 100 / 47,139 |
| Consumed successfully | 10 of 10 documents |
| Upload of all PDFs (queued) | 1.0 s |
| First upload to last document finished | 272 s |
| seconds per page, whole batch | 2.7 |
| Seconds per page, per document (task start to done) | 2.5 (2.4 to 2.5) |
| Total RAM, all containers: idle before, median (MiB) | 670 |
| Total RAM during the batch: median / peak (MiB) | 834 / 1022 |
| Total CPU during the batch: mean / peak (% of one vCPU) | 215 / 401 |
| OCR character error rate, per document | 0.01 % (0.00 to 0.05 %) |
| OCR word error rate, per document | 0.08 % (0.00 to 0.30 %) |
| Phrase searches that returned the right document | 10 of 10 |
| Phrase search time (30 queries) | 54 ms (34 to 69 ms) |
One batch on a 4 vCPU, 4 GB VM with the stack's default settings. Per-document figures are median (range) over the 10 documents. Error rates: Levenshtein distance between the stored OCR text and the text drawn on the pages, divided by the length of the drawn text, after collapsing whitespace. The pages are clean, generated renderings, which is easier than real scans.
| Container | Idle before (MiB) | Peak during the batch (MiB) | Peak CPU (% of one vCPU) |
|---|---|---|---|
| broker | 13 | 13 | 0 |
| db | 73 | 80 | 17 |
| webserver | 584 | 930 | 400 |
Samples come from docker stats --no-stream and are about 2 s apart, during the batch and at idle.
- Per page: 2.5 s median per document (2.4 to 2.5 s, from each task’s own duration) and 2.7 s counting the whole batch. Ten documents give ten timings, which are close together, so the figure is stable on this input; the test did not repeat the whole batch.
- The webserver container did the OCR. It peaked at 930 MiB and 400 % of one vCPU (all 4 cores) while the batch ran (RAM and CPU samples come from
docker stats --no-streamand are about 2 s apart), and averaged 215 % across all containers. - The stack idled at 670 MiB before the batch (the stack table’s figure, taken at first run, is lower). The media volume ended at 120.2 MB for 58.6 MB of input PDFs; what the extra consists of was not itemized.
| Stack | Version | RAM at idle | CPU at idle | Images on disk | Ready after |
|---|---|---|---|---|---|
| Paperless-ngx | 3.4.1 | 0.40% | 4273.4 MB | 26.1 s |
OCR accuracy
For each document the stored text was compared with the drawn text after collapsing whitespace. Character error rate is the Levenshtein distance divided by the length of the drawn text; word error rate is the same on words. Every difference counts, including punctuation and capitalization.
| Document | Pages | Task time | Character error | Word error |
|---|---|---|---|---|
| a-christmas-carol | 10 | 25 s | 0.05 % | 0.30 % |
| pride-and-prejudice | 10 | 25 s | 0.01 % | 0.13 % |
| frankenstein | 10 | 25 s | 0.00 % | 0.02 % |
| moby-dick | 10 | 25 s | 0.00 % | 0.00 % |
| tale-of-two-cities | 10 | 24 s | 0.01 % | 0.13 % |
| sherlock-holmes | 10 | 25 s | 0.00 % | 0.00 % |
| tom-sawyer | 10 | 24 s | 0.01 % | 0.09 % |
| dracula | 10 | 25 s | 0.01 % | 0.08 % |
| treasure-island | 10 | 24 s | 0.01 % | 0.12 % |
| dorian-gray | 10 | 25 s | 0.00 % | 0.02 % |
Source texts are public-domain Project Gutenberg books.
The median document had a 0.01 % character error rate and 0.08 % word error rate. The worst was A Christmas Carol at 0.05 % and 0.30 %. Looking at that document’s differences, its 13 differences were of three kinds: straight quotes in the source that came back as curly quotes ("At became “At), a few cases where words were joined (in a became ina, half-recumbent became halfrecumbent), and one doubled apostrophe. Under a stricter or looser comparison the percentages would move, and only this one document’s differences were reviewed.
Search
For each document, six consecutive words from page 5 were searched as an exact phrase through GET /api/documents/?query="...".
| Document | Phrase | Found in that document | Documents returned | Median query time |
|---|---|---|---|---|
| a-christmas-carol | strutted in the stables; and the | yes | 1 | 61 ms |
| pride-and-prejudice | were very minute, and then by | yes | 1 | 41 ms |
| frankenstein | he repeated my favourite poems, or | yes | 1 | 43 ms |
| moby-dick | sea, and a great lord of | yes | 1 | 44 ms |
| tale-of-two-cities | echoes of other steps that never | yes | 1 | 40 ms |
| sherlock-holmes | a very coarse one and was | yes | 1 | 64 ms |
| tom-sawyer | flower-pots with him. The old lady | yes | 1 | 61 ms |
| dracula | velvet band again covered the red | yes | 1 | 38 ms |
| treasure-island | the south-west of us we saw | yes | 1 | 54 ms |
| dorian-gray | empty street. The air was heavy | yes | 1 | 58 ms |
GET /api/documents/?query="..." (full-text index), 3 passes.
All 10 phrases returned exactly one document, the right one, with a median of 54 ms (34 to 69 ms, 30 queries). That tests the full-text index on clean text. It does not test fuzzy or partial queries, rare words across many thousands of documents, or non-English text.
Reproduce it
python3 gen.py ~/scans 10 10 # generated scans + ground truth
docker compose -f compose.yml -f admin.yml up -d # admin.yml, shown below
python3 bench.py ~/scans ~/out # upload, wait for the tasks, compare text, search
The admin.yml used in the test (the password is the lab’s placeholder; pick your own before using this anywhere real):
services:
webserver:
environment:
PAPERLESS_ADMIN_USER: admin
PAPERLESS_ADMIN_PASSWORD: lab-only-not-a-secret
This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
services:
broker:
image: docker.io/valkey/valkey:9-alpine
restart: unless-stopped
volumes:
- redisdata:/data
db:
image: docker.io/library/postgres:18
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: lab-only-not-a-secret
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:3.4.1
restart: unless-stopped
depends_on: [db, broker]
ports:
- "127.0.0.1:8080:8000"
volumes:
- data:/usr/src/paperless/data
- media:/usr/src/paperless/media
- ./export:/usr/src/paperless/export
- ./consume:/usr/src/paperless/consume
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
PAPERLESS_DBPASS: lab-only-not-a-secret
PAPERLESS_SECRET_KEY: lab-only-not-a-secret-lab-only-not-a-secret
volumes:
data:
media:
pgdata:
redisdata:This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
#!/usr/bin/env python3
"""Runs in the VM. bench.py <scans_dir> <outdir>
Uploads every PDF in <scans_dir>/pdf through POST /api/documents/post_document/ one after another (queued, as a scanner or a script would),
waits for the consumption tasks, then compares the OCR text Paperless stored with the exact text that was drawn on the pages (truth/),
and runs phrase searches through GET /api/documents/?query=."""
import sys, os, json, time, glob, re, urllib.request, urllib.error, urllib.parse, uuid, datetime, statistics as st
from rapidfuzz.distance import Levenshtein
scans, out = sys.argv[1:3]; os.makedirs(out, exist_ok=True); U = "http://127.0.0.1:8080"
def call(method, path, body=None, headers=None, raw=None):
h = dict(headers or {}); data = raw
if body is not None: data = json.dumps(body).encode(); h["Content-Type"] = "application/json"
r = urllib.request.Request(U + path, data=data, method=method, headers=h)
try:
with urllib.request.urlopen(r, timeout=300) as x: t = x.read(); return x.status, (json.loads(t) if t else None)
except urllib.error.HTTPError as e: return e.code, e.read().decode()[:400]
st_, r = call("POST", "/api/token/", {"username": "admin", "password": "lab-only-not-a-secret"}); TOK = {"Authorization": "Token " + r["token"]}
def upload(path, title):
b = uuid.uuid4().hex; name = os.path.basename(path); data = open(path, "rb").read()
def f(k, v): return f'--{b}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
body = f( "title", title) + f'--{b}\r\nContent-Disposition: form-data; name="document"; filename="{name}"\r\nContent-Type: application/pdf\r\n\r\n'.encode() + data + f"\r\n--{b}--\r\n".encode()
return call("POST", "/api/documents/post_document/", headers={**TOK, "Content-Type": f"multipart/form-data; boundary={b}"}, raw=body)
meta = {m["doc"]: m for m in json.load(open(f"{scans}/meta.json"))}; pdfs = sorted(glob.glob(f"{scans}/pdf/*.pdf")); tasks = {}
res = {"docs": len(pdfs), "pages": sum(m["pages"] for m in meta.values())}
t0 = time.time()
for p in pdfs:
slug = os.path.basename(p)[:-4]; s, r = upload(p, slug); assert s == 200, (s, r); tasks[slug] = r.strip('"') if isinstance(r, str) else r
res["upload_secs"] = time.time() - t0; print("uploaded", res["upload_secs"], flush=True)
def task(tid):
s, r = call("GET", f"/api/tasks/?task_id={tid}", headers=TOK)
res_ = r.get("results") if isinstance(r, dict) else r
return res_[0] if res_ else None
done = {}; res["poll_log"] = []
while len(done) < len(tasks) and time.time() - t0 < 5400:
for slug, tid in tasks.items():
if slug in done: continue
t = task(tid)
if t and t.get("status") in ("success", "failure"): done[slug] = (t, time.time())
res["poll_log"].append([round(time.time() - t0, 1), len(done)]); time.sleep(3)
t_end = time.time(); res["total_secs"] = t_end - t0; res["tasks"] = {}
for slug, (t, _) in done.items():
res["tasks"][slug] = {"status": t["status"], "pages": meta[slug]["pages"], "started_to_done": t.get("duration_seconds"), "wait_secs": t.get("wait_time_seconds"), "result": str(t.get("result_data"))[:200]}
res["finished_ok"] = sum(1 for v in res["tasks"].values() if v["status"] == "success")
# accuracy: stored OCR text vs the text drawn on the pages
s, r = call("GET", "/api/documents/?page_size=100&fields=id,title,content,page_count", headers=TOK); docs = {d["title"]: d for d in r["results"]}
norm = lambda t: " ".join(t.replace("\f", " ").split())
acc = []
for slug in meta:
if slug not in docs: acc.append({"doc": slug, "missing": True}); continue
truth = norm(open(f"{scans}/truth/{slug}.txt").read()); got = norm(docs[slug]["content"])
cer = Levenshtein.distance(truth, got) / len(truth); tw, gw = truth.split(), got.split(); wer = Levenshtein.distance(tw, gw) / len(tw)
acc.append({"doc": slug, "chars": len(truth), "words": len(tw), "cer": cer, "wer": wer, "page_count": docs[slug].get("page_count")})
res["accuracy"] = acc
os.makedirs(f"{out}/ocr", exist_ok=True); os.makedirs(f"{out}/truth", exist_ok=True)
for slug in meta:
if slug in docs: open(f"{out}/ocr/{slug}.txt", "w").write(docs[slug]["content"])
open(f"{out}/truth/{slug}.txt", "w").write(open(f"{scans}/truth/{slug}.txt").read())
# search: 6 consecutive words from page 5 of each document, as an exact phrase, 3 passes for timing
searches = []
for slug in meta:
pages = open(f"{scans}/truth/{slug}.txt").read().split("\f"); words = [w for w in " ".join(pages[min(4, len(pages) - 1)].split()).split()]
phrase = " ".join(words[40:46]); q = urllib.parse.quote(f'"{phrase}"'); times = []; ids = []
for _ in range(3):
t = time.perf_counter(); s, r = call("GET", f"/api/documents/?query={q}&fields=id,title", headers=TOK); times.append((time.perf_counter() - t) * 1000)
ids = [d["title"] for d in r["results"]] if s == 200 else [f"HTTP {s}"]
searches.append({"doc": slug, "phrase": phrase, "hits": ids, "found_in_expected": slug in ids, "ms": times})
res["search"] = searches
json.dump(res, open(f"{out}/bench.json", "w"), indent=1); print("done", res["total_secs"], res["finished_ok"])What went wrong
The first complete run was thrown away. Two of the ten books (Alice in Wonderland and The Yellow Wallpaper) ran out of long paragraphs after a few pages, so about a dozen of the “100” pages were blank, and the page-5 search phrases for them were empty. The generator now refuses to produce a page with fewer than 25 lines, and those two books were replaced by A Christmas Carol and Treasure Island. The same run’s task polling also assumed upper-case status values from an older API, so the script waited on tasks that had already finished; it now reads duration_seconds and lower-case statuses from the 3.4.1 API. The reported run is the second complete one. The logs of the discarded run were not kept, so that description cannot be checked against evidence.
What this test did not cover
Sources and how this was made
This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.
Show the full file
#!/usr/bin/env bash
# paperless: Paperless-ngx 3.4.1 OCR on 4 vCPU. 100 pages (10 documents x 10 pages) of public-domain Project Gutenberg text rendered
# as scanned-looking, image-only PDFs; consumed through POST /api/documents/post_document/. Time per page, RAM, OCR error rate, phrase search.
set -uo pipefail
source "$(dirname "$0")/../_e/lib.sh"
T=paperless; D="$ROOT/lab/tests/$T"; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"
rm -rf "$OUT"; mkdir -p "$RAW" "$ROOT/public/shots/$T"
start_vm e-$T || exit 1
collect_env "$OUT"
gx 'sudo apt-get install -y -qq python3-pil python3-numpy python3-rapidfuzz fonts-dejavu-core >/dev/null 2>&1; mkdir -p ~/pl'
vm scp-to "$D/gen.py" gen.py; vm scp-to "$D/bench.py" bench.py; vm scp-to "$ROOT/lab/tests/_e/sampler.py" sampler.py; vm scp-to "$ROOT/lab/stacks/paperless/compose.yml" pl/compose.yml
gx 'python3 gen.py ~/scans 10 10' | tee "$RAW/dataset.txt"
gx 'cat ~/scans/meta.json' > "$RAW/dataset-meta.json"
# admin account through environment variables (not in the stack compose)
gx 'cd ~/pl && printf "services:\n webserver:\n environment:\n PAPERLESS_ADMIN_USER: admin\n PAPERLESS_ADMIN_PASSWORD: lab-only-not-a-secret\n" > admin.yml; docker compose -f compose.yml -f admin.yml pull -q 2>&1 | tail -1; docker images --format "{{.Repository}}:{{.Tag}} {{.ID}}" | sort' > "$RAW/images.txt" 2>&1
gx 'cd ~/pl && docker compose -f compose.yml -f admin.yml up -d >/dev/null 2>&1; for i in $(seq 1 200); do [ "$(curl -s -o /dev/null -w %{http_code} http://127.0.0.1:8080/api/)" != 000 ] && curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/accounts/login/ | grep -q 200 && exit 0; sleep 3; done; exit 1'
gx 'sleep 90'
gx 'cd ~/pl && docker compose exec -T webserver sh -c "nproc; env | grep -E \"PAPERLESS_(TASK|THREADS|OCR|WORKERS)\" ; python3 -c \"import sys;print(sys.version.split()[0])\"; tesseract --version 2>&1 | head -1; ocrmypdf --version 2>&1 | head -1"' > "$RAW/paperless-env.txt" 2>&1
gx "timeout 40 python3 sampler.py /tmp/idle0.csv 2 || true; cat /tmp/idle0.csv" > "$RAW/mem-idle-before.csv"
gx 'nohup python3 sampler.py /tmp/run.csv 1 >/dev/null 2>&1 & echo $! > /tmp/sampler.pid'
gx 'python3 bench.py ~/scans ~/out' 2>&1 | tee "$RAW/bench.log" | cut -c1-200
gx 'sleep 60; kill $(cat /tmp/sampler.pid); sleep 2; cat /tmp/run.csv' > "$RAW/mem-run.csv"
gx 'cat ~/out/bench.json' > "$RAW/bench.json"; mkdir -p "$RAW/text"; vm scp-from '~/out/ocr' "$RAW/text/"; vm scp-from '~/out/truth' "$RAW/text/"
gx 'cd ~/pl && sudo du -sb /var/lib/docker/volumes/pl_media /var/lib/docker/volumes/pl_data /var/lib/docker/volumes/pl_pgdata | cat' > "$RAW/disk.txt" 2>&1
gx 'cd ~/pl && docker compose logs --no-color webserver 2>&1 | grep -iE "warn|error|ocr" | tail -15 | cut -c1-250' > "$RAW/webserver-log-tail.txt" 2>&1
tunnel 18092 8080
( cd "$D" && node shot.mjs http://127.0.0.1:18092 "$ROOT/public/shots/$T/documents.png" ) > "$RAW/shot.txt" 2>&1
untunnel
python3 "$D/summarize.py" "$RAW" "$OUT"How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.