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.

Extraction time for a 250-file sample On a sample of 250 mixed business PDFs totalling 3,900 pages, PyMuPDF extracted plain text in 9 seconds and sorted text in 11 seconds. pypdf's default extraction took 128 seconds and its layout mode took 214 seconds on the same machine and files. 250 PDFs, 3,900 pages, single core PyMuPDF get_text() 9 s PyMuPDF get_text(sort=True) 11 s pypdf extract_text() 128 s pypdf layout mode 214 s Absolute numbers depend on hardware and documents; the ratio is what to measure on your own sample

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 needPreferWhy
No compiled dependencies (restricted servers, some serverless)pypdfPure Python, installs anywhere
Distributing closed-source softwarepypdf, or PyMuPDF with commercial licenceMuPDF is AGPL
High volume or latency-sensitivePyMuPDFTypically 10× faster or more
Multi-column reading orderPyMuPDF (sort=True, blocks)Layout analysis from the renderer
Visual alignment of simple tablespypdf layout modePads with spaces to mimic positions
Fonts without Unicode mapsPyMuPDFBetter recovery from embedded font tables
Also need rendering, redaction, imagesPyMuPDFSame library covers all of it
PyMuPDF and pypdf compared PyMuPDF installs as a compiled wheel, is licensed AGPL or commercial, is fast, gives good reading order with sort and block output, has no space-padded layout mode, recovers more text from fonts without Unicode maps, and also renders, redacts and edits. pypdf is pure Python, BSD licensed, slower, gives acceptable reading order for simple layouts, offers a layout mode that preserves visual alignment, recovers less from broken fonts, and focuses on reading, splitting, merging and forms. Aspect PyMuPDF pypdf Install compiled wheel pure Python Licence AGPL or commercial BSD Speed very fast slow on large files Reading order sort and blocks simple layouts Visual layout text no padded mode layout mode Broken font maps better recovery often garbage Beyond text render, redact, edit split, merge, forms

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.

Picking an engine for a pipeline The root asks whether AGPL licensing or compiled dependencies are a blocker. If licensing blocks MuPDF and no commercial licence is available, use pypdf and route unreadable files to OCR. If only compiled dependencies are restricted but an optional extra is allowed, use pypdf with a PyMuPDF fallback for files failing a quality check. If neither is a blocker, use PyMuPDF for speed, reading order and font recovery. Is AGPL or a compiled wheel a blocker? check licence and deployment licence blocks pypdf only pure Python wheels restricted pypdf first quality check per file no blockers PyMuPDF fast Unreadable files route to OCR PyMuPDF fallback optional extra

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.

Part of Comparing PDF Table Extraction Libraries.