PyMuPDF vs pypdf for Text Extraction
A pipeline needs the text of 40,000 PDFs a month, and the team cannot agree on a library. One script uses pypdf because it is pure Python and installs anywhere; another uses PyMuPDF because it was faster in a notebook. The outputs differ — spaces in different places, columns in a different order, a handful of files where one library returns readable text and the other returns (cid:..)-style garbage or nothing — and nobody knows whether the difference matters or which one is right.
Root Cause
The two libraries are built differently, and every visible difference follows from that. pypdf is a pure-Python PDF parser: it interprets content streams in Python and reconstructs text by tracking text positioning operators, which makes it portable, dependency-free and comparatively slow. PyMuPDF is a binding to MuPDF, a C rendering engine: text extraction reuses the same layout analysis the renderer uses, which makes it fast and generally better at reading order and font decoding, but it ships compiled binaries and carries MuPDF's AGPL licence unless a commercial licence is bought. Neither has a "correct" text output, because PDFs do not store words or reading order — each library applies its own heuristics for spaces, lines and block order. The right choice depends on your documents and constraints, and the only reliable way to decide is to measure both on a sample of your own files.
Minimal Diagnostic
Extract the same pages with both libraries, time them, and compare the outputs with simple quality signals: characters extracted, share of dictionary-like words, and similarity between the two results.
# pip install pymupdf pypdf
import difflib
import re
import time
from pathlib import Path
import pymupdf
from pypdf import PdfReader
SAMPLE_DIR = Path("in/sample")
COMMON = {"the", "and", "of", "to", "in", "for", "invoice", "total", "date", "page"}
def score(text: str) -> float:
words = re.findall(r"[a-z]{2,}", text.lower())
return sum(w in COMMON for w in words) / (len(words) or 1)
def extract_pymupdf(path: Path) -> str:
with pymupdf.open(path) as doc:
return "\n".join(page.get_text(sort=True) for page in doc)
def extract_pypdf(path: Path) -> str:
reader = PdfReader(path)
return "\n".join(page.extract_text() or "" for page in reader.pages)
def compare(folder: Path) -> None:
totals = {"pymupdf": 0.0, "pypdf": 0.0}
for path in sorted(folder.glob("*.pdf")):
results = {}
for name, fn in (("pymupdf", extract_pymupdf), ("pypdf", extract_pypdf)):
start = time.perf_counter()
try:
text = fn(path)
except Exception as exc:
text = f"ERROR {exc}"
totals[name] += time.perf_counter() - start
results[name] = text
a, b = (" ".join(results[k].split()) for k in ("pymupdf", "pypdf"))
similarity = difflib.SequenceMatcher(None, a[:5000], b[:5000]).ratio()
print(f"{path.name:<28} chars {len(a):>6}/{len(b):<6} score {score(a):.2f}/{score(b):.2f} sim {similarity:.2f}")
print({k: f"{v:.1f}s" for k, v in totals.items()})
if __name__ == "__main__":
compare(SAMPLE_DIR)
annual-report-2025.pdf chars 182410/181977 score 0.19/0.18 sim 0.91
brochure-two-column.pdf chars 9812/9840 score 0.17/0.11 sim 0.54
supplier-invoice-cid.pdf chars 1422/1433 score 0.12/0.00 sim 0.08
statement-letter.pdf chars 3108/3120 score 0.21/0.21 sim 0.97
{'pymupdf': '2.3s', 'pypdf': '31.8s'}
Most files agree closely. The two-column brochure differs in reading order (low similarity, lower score for pypdf); the invoice with a custom-encoded font decodes with PyMuPDF and not with pypdf; and on this sample PyMuPDF is about fourteen times faster.
Fix: Choose by Your Constraints, Then Standardise
Pick one library per pipeline using the constraints that are hard to change — licensing and deployment — and then the measured quality on your documents. Wrap it behind one function so the rest of the code never depends on which library produced the text.
# pip install pymupdf pypdf
from pathlib import Path
def extract_text(path: Path, engine: str = "pymupdf") -> list[str]:
"""One string per page, reading order, whitespace normalised within lines."""
if engine == "pymupdf":
import pymupdf
flags = pymupdf.TEXT_DEHYPHENATE | pymupdf.TEXT_MEDIABOX_CLIP # changed: engine options in one place
try:
with pymupdf.open(path) as doc:
return [page.get_text("text", flags=flags, sort=True) for page in doc]
except (pymupdf.FileDataError, RuntimeError) as exc:
raise RuntimeError(f"pymupdf could not read {path.name}: {exc}") from exc
if engine == "pypdf":
from pypdf import PdfReader
from pypdf.errors import PdfReadError
try:
reader = PdfReader(path)
if reader.is_encrypted:
reader.decrypt("") # changed: empty owner password
return [page.extract_text(extraction_mode="plain") or "" for page in reader.pages]
except PdfReadError as exc:
raise RuntimeError(f"pypdf could not read {path.name}: {exc}") from exc
raise ValueError(f"unknown engine {engine!r}")
The decision table below reflects typical results; your measured sample overrides it.
| Constraint or need | Prefer | Why |
|---|---|---|
| No compiled dependencies (restricted servers, some serverless) | pypdf | Pure Python, installs anywhere |
| Distributing closed-source software | pypdf, or PyMuPDF with commercial licence | MuPDF is AGPL |
| High volume or latency-sensitive | PyMuPDF | Typically 10× faster or more |
| Multi-column reading order | PyMuPDF (sort=True, blocks) | Layout analysis from the renderer |
| Visual alignment of simple tables | pypdf layout mode | Pads with spaces to mimic positions |
| Fonts without Unicode maps | PyMuPDF | Better recovery from embedded font tables |
| Also need rendering, redaction, images | PyMuPDF | Same library covers all of it |
Variant Fix 1: Use Both — Fall Back per File
In pipelines that must stay pure Python by default but cannot afford unreadable output, use pypdf first and fall back to PyMuPDF (installed as an optional extra) only for files that fail a quality check:
# pip install pypdf (optional: pymupdf)
import re
from pathlib import Path
COMMON = {"the", "and", "of", "to", "in", "for", "total", "date", "invoice", "page"}
def looks_readable(pages: list[str]) -> bool:
text = " ".join(pages)
words = re.findall(r"[A-Za-z]{2,}", text)
if len(text.strip()) < 50:
return False
common = sum(w.lower() in COMMON for w in words) / (len(words) or 1)
return "(cid:" not in text and "\N{REPLACEMENT CHARACTER}" not in text and common >= 0.02
def extract_with_fallback(path: Path) -> tuple[str, list[str]]:
pages = extract_text(path, "pypdf")
if looks_readable(pages):
return "pypdf", pages
try:
import pymupdf # noqa: F401
except ImportError:
return "pypdf-unreadable", pages
return "pymupdf", extract_text(path, "pymupdf")
Record which engine produced each document. When downstream quality problems appear, that field tells you immediately whether they cluster on fallback files. Files that neither library reads are usually scans or broken font encodings, handled in fix CID garbled characters in PDF text.
Variant Fix 2: Normalise Output Differences Before Comparing or Indexing
When results from both libraries coexist — during a migration, or with the fallback above — normalise text so search and deduplication do not treat trivial differences as changes:
# stdlib only
import re
import unicodedata
def normalise_text(text: str) -> str:
text = unicodedata.normalize("NFKC", text) # ligatures, full-width forms
text = re.sub(r"-\n(?=[a-z])", "", text) # join hyphenated line breaks
text = re.sub(r"[ \t]+", " ", text) # collapse runs of spaces (layout padding)
text = re.sub(r" *\n *", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
After normalisation, similarity between the two engines on single-column documents typically rises above 0.95, which makes genuine differences — reading order, missing text — stand out in a comparison report.
Running the Benchmark Properly
A fair comparison controls for caching and measures what the pipeline will actually do. Run each engine in a fresh process, repeat, and keep the per-file results rather than only totals:
# pip install pymupdf pypdf "pandas>=2.2"
import json
import subprocess
import sys
from pathlib import Path
import pandas as pd
WORKER = """
import json, sys, time
from pathlib import Path
from extract import extract_text # the wrapper from the fix
engine, path = sys.argv[1], Path(sys.argv[2])
t = time.perf_counter()
try:
pages = extract_text(path, engine); ok = True
except Exception:
pages, ok = [], False
print(json.dumps({"engine": engine, "file": path.name, "ok": ok,
"seconds": time.perf_counter() - t, "chars": sum(map(len, pages))}))
"""
def benchmark(folder: Path, repeats: int = 3) -> pd.DataFrame:
rows = []
for path in sorted(folder.glob("*.pdf")):
for engine in ("pymupdf", "pypdf"):
for _ in range(repeats):
out = subprocess.run([sys.executable, "-c", WORKER, engine, str(path)],
capture_output=True, text=True, timeout=300)
if out.stdout.strip():
rows.append(json.loads(out.stdout.strip().splitlines()[-1]))
frame = pd.DataFrame(rows)
return frame.groupby(["engine", "file"]).agg(seconds=("seconds", "median"),
chars=("chars", "max"), ok=("ok", "all")).reset_index()
Median timings from separate processes include import cost, which matters for short-lived jobs that start per file. Choose the sample from production, not from test fixtures: 50 to 100 real files of each document type is enough to see reading-order and encoding differences clearly.
Verification
After switching or standardising, confirm the chosen engine meets the pipeline's requirements on a fixed regression set, and keep that set in version control so library upgrades are checked the same way.
# pip install pymupdf pypdf
import json
from pathlib import Path
def verify_engine(engine: str, regression_dir: Path, expectations: Path) -> None:
expected = json.loads(expectations.read_text(encoding="utf-8"))
failures = []
for name, phrases in expected.items():
text = " ".join(" ".join(extract_text(regression_dir / name, engine)).split())
missing = [p for p in phrases if p not in text]
if missing:
failures.append(f"{name}: missing {missing}")
assert not failures, "\n".join(failures)
print(f"{engine}: {len(expected)} regression file(s) contain all expected phrases")
# expectations.json: {"supplier-invoice-cid.pdf": ["Invoice total", "1,240.50"], ...}
Checking for specific phrases — an invoice total, a clause heading, a customer name — tests what the pipeline depends on, rather than an arbitrary similarity score. Re-run it on every library upgrade; both projects change extraction heuristics between releases.
FAQ
Is pdfplumber an alternative to both? For character-level control and tables, yes. It is built on pdfminer.six, pure Python, and slower than PyMuPDF; its text output resembles pypdf's in speed class and PyMuPDF's in positional detail.
Does pypdf's layout mode replace table extraction? For simple aligned tables it produces fixed-width text you can split, but merged cells and wrapped text break it. Use a table extractor for real tables.
Can I use PyMuPDF in a commercial SaaS? AGPL obligations can apply to network use; consult the licence and your legal team, or buy a commercial licence from the MuPDF vendor.
Which is better for encrypted PDFs? Both open files with an empty user password and can decrypt with a known password. Neither can extract text from files whose password you do not have.
Related
- Comparing PDF Table Extraction Libraries — pdfplumber, camelot and tabula for tables
- Extracting Text and Metadata from PDFs with Python — reading order, headers and quality checks
- Fix pdfplumber Returns Empty Text — when every library returns nothing
- Fix pypdf PdfFileReader Deprecation Error — current pypdf API names