pdfplumber vs PyMuPDF for Carrier PDFs
Choosing between pdfplumber and PyMuPDF for a native-text extraction tier is a decision about coordinate fidelity, throughput, and licensing — and this guide, a focused comparison under PDF Text Extraction with pdfplumber, resolves it against realistic carrier declarations pages rather than benchmark toys. Both libraries turn a digital-origin policy PDF into text with bounding boxes, but they make opposite trade-offs, and picking the wrong one leaves you either paying a 10-40x speed penalty at portfolio scale or shipping AGPL-licensed code you cannot legally distribute closed-source.
Problem Statement
Permalink to "Problem Statement"A carrier declarations page is deceptively hostile. The policy number, the named insured, the Coverage A dwelling limit, and a stack of endorsement codes are laid out in a grid that carries no semantic markup — only characters at (x, top) coordinates on a page. A coordinate-aware extractor reads the value inside a known bounding box; a flat extract_text() over the whole page flattens that layout into a line stream that a regex must then re-segment, which is exactly the silent corruption failure the parent stage exists to prevent.
Both pdfplumber and PyMuPDF expose the coordinate space, so both can drive a within_bbox-style extractor. The decision only bites at production scale, along three axes that pull in different directions. Throughput: a renewal-season surge or a post-catastrophe loss run pushes tens of thousands of documents per hour through the worker pool, and pdfplumber — pure Python on pdfminer.six — is materially slower than PyMuPDF’s C-backed MuPDF core. Character-level fidelity: pdfplumber exposes per-character font, size, and stroke metadata that a confidence heuristic and a CID-font diagnostic depend on; PyMuPDF exposes a coarser word/span model that is faster but discards some of that detail. Licensing: pdfplumber is MIT, PyMuPDF is AGPL v3 (or a paid commercial license). If your extraction service is distributed to carriers on-premises, or is part of a SaaS whose legal posture avoids AGPL obligations, that single fact can veto PyMuPDF regardless of its speed. This page benchmarks both, then gives a verdict grounded in where each belongs in a claims pipeline.
Prerequisites
Permalink to "Prerequisites"Both engines target Python 3.10+. Pin them and the transitive PDF parser so extraction stays reproducible across deploys — an unpinned pdfminer.six changes pdfplumber’s character clustering, and an unpinned MuPDF changes PyMuPDF’s word boxes.
python -m venv .venv && source .venv/bin/activate
pip install "pdfplumber==0.11.4" "pdfminer.six==20240706" \
"PyMuPDF==1.24.10" "structlog==24.*"
Import names diverge and this trips people up: pdfplumber imports as pdfplumber, but PyMuPDF imports as fitz. Both accept a file path or a bytes buffer; in a stateless worker you read immutable bytes from object storage and never from the inbound request, exactly as the parent stage requires.
Step-by-Step Implementation
Permalink to "Step-by-Step Implementation"Step 1 — Extract a declarations field with pdfplumber
Permalink to "Step 1 — Extract a declarations field with pdfplumber"pdfplumber reads a coordinate-scoped region through within_bbox, returning the text plus, if you want it, the underlying character objects with font and size. That per-character detail is what a confidence heuristic and a CID-font diagnostic consume.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
import pdfplumber
import structlog
log = structlog.get_logger("extract.pdfplumber")
# (x0, top, x1, bottom) in PDF points, origin top-left.
BBox = tuple[float, float, float, float]
@dataclass(frozen=True, slots=True)
class FieldRead:
value: str
char_count: int
mean_size: float
def read_field_pdfplumber(pdf_path: str, page_index: int, bbox: BBox) -> FieldRead:
with pdfplumber.open(pdf_path) as pdf:
page = pdf.pages[page_index]
region = page.within_bbox(bbox)
text = (region.extract_text() or "").strip()
chars = region.chars # per-char dicts: fontname, size, x0, top, ...
mean_size = (
sum(c["size"] for c in chars) / len(chars) if chars else 0.0
)
log.info("pdfplumber.field", value=text, chars=len(chars))
return FieldRead(value=text, char_count=len(chars), mean_size=mean_size)
def coverage_a_limit(pdf_path: str, bbox: BBox) -> Decimal:
raw = read_field_pdfplumber(pdf_path, 0, bbox).value
return Decimal(raw.replace("$", "").replace(",", "").strip() or "0")
Step 2 — Extract the same field with PyMuPDF
Permalink to "Step 2 — Extract the same field with PyMuPDF"PyMuPDF clips to a rectangle with page.get_text("text", clip=...), or returns word tuples via get_text("words") — each a (x0, y0, x1, y1, word, block, line, word_no) record you can filter by intersection. It is dramatically faster, but note the coordinate origin and the coarser granularity: you get words and spans, not per-glyph stroke metadata.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
import fitz # PyMuPDF
import structlog
log = structlog.get_logger("extract.pymupdf")
Rect = tuple[float, float, float, float] # (x0, y0, x1, y1)
@dataclass(frozen=True, slots=True)
class FieldRead:
value: str
word_count: int
def read_field_pymupdf(pdf_path: str, page_index: int, rect: Rect) -> FieldRead:
with fitz.open(pdf_path) as doc:
page = doc[page_index]
clip = fitz.Rect(*rect)
text = page.get_text("text", clip=clip).strip()
words = [w for w in page.get_text("words") if fitz.Rect(w[:4]).intersects(clip)]
log.info("pymupdf.field", value=text, words=len(words))
return FieldRead(value=text, word_count=len(words))
def coverage_a_limit(pdf_path: str, rect: Rect) -> Decimal:
raw = read_field_pymupdf(pdf_path, 0, rect).value
return Decimal(raw.replace("$", "").replace(",", "").strip() or "0")
Both functions produce the same normalized Decimal for the Coverage A limit — currency math never touches float — so from the caller’s perspective they are drop-in interchangeable behind a common read_field protocol. The difference is entirely in cost and metadata, which the next section measures.
Step 3 — Wrap both behind one benchmarkable interface
Permalink to "Step 3 — Wrap both behind one benchmarkable interface"To compare fairly, drive both through an identical signature so the only variable is the engine. A tiny adapter lets the batch orchestrator swap engines from configuration rather than from a rewrite.
from typing import Protocol
class FieldExtractor(Protocol):
def coverage_a_limit(self, pdf_path: str, box: tuple[float, ...]) -> Decimal: ...
ENGINES: dict[str, FieldExtractor] = {} # populated by pdfplumber / pymupdf adapters
def extract_with(engine: str, pdf_path: str, box: tuple[float, ...]) -> Decimal:
return ENGINES[engine].coverage_a_limit(pdf_path, box)
Verification & Benchmark
Permalink to "Verification & Benchmark"Correctness first: assert both engines return the identical Decimal on a known-good declarations fixture, so a benchmark can never trade accuracy for speed silently. Then time each over a representative batch and record wall-clock throughput and peak RSS.
import time
import tracemalloc
from decimal import Decimal
def test_engines_agree_on_limit(fixture_pdf: str) -> None:
box = (360.0, 118.0, 520.0, 138.0)
plumber = extract_with("pdfplumber", fixture_pdf, box)
mupdf = extract_with("pymupdf", fixture_pdf, box)
assert plumber == mupdf == Decimal("500000") # exact — no tolerance band
def benchmark(engine: str, pdfs: list[str], box: tuple[float, ...]) -> dict[str, float]:
tracemalloc.start()
start = time.perf_counter()
for path in pdfs:
extract_with(engine, path, box)
elapsed = time.perf_counter() - start
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return {
"engine": engine,
"docs_per_sec": round(len(pdfs) / elapsed, 1),
"peak_mb": round(peak / (1024 * 1024), 1),
}
Run both over the same fixture set and compare the two dicts. The consistent finding on clean single-page declarations is that PyMuPDF clears many multiples of pdfplumber’s documents-per-second at a lower peak, while pdfplumber’s chars list gives you a per-field confidence signal PyMuPDF cannot cheaply reproduce. Measure on your documents — the ratio widens on page-heavy commercial binders and narrows on sparse auto ID cards.
Here is how the two stack up on the criteria that actually drive the decision:
| Criterion | pdfplumber | PyMuPDF (fitz) |
Who wins for carrier PDFs |
|---|---|---|---|
| Throughput | Pure Python on pdfminer.six; the batch bottleneck under surge load |
MuPDF C core, roughly 10-40x faster | PyMuPDF |
| Peak memory | Materializes char objects per page; heavier on 90-page binders | Streams text incrementally; smaller resident set | PyMuPDF |
| Coordinate fidelity | Per-character font, size, and stroke metadata via page.chars |
Word/span model; coarser, no per-glyph strokes | pdfplumber |
| CID / mojibake diagnosis | (cid:NN) tokens visible in chars for the parent stage’s diagnostic |
Reports missing glyphs but with less granularity | pdfplumber |
| Table access | Mature extract_tables with line and text strategies |
find_tables since 1.23; fast but newer |
Roughly even |
| Encrypted PDFs | password= on open |
doc.authenticate(pw) |
Even |
| Licensing | MIT — ships in any product | AGPL v3 or paid commercial license | pdfplumber |
When to Choose Which
Permalink to "When to Choose Which"Reach for PyMuPDF when raw throughput is the binding constraint and AGPL is acceptable to your legal posture: a high-volume, first-party SaaS ingesting hundreds of thousands of clean digital-origin declarations a day, where the C core turns a multi-hour batch into minutes. It is also the better default when you are rasterizing pages to feed the optical fallback described in OCR Integration & Sync, because PyMuPDF’s page.get_pixmap() render path is faster than routing through an external tool.
Reach for pdfplumber when coordinate fidelity or licensing dominates. Its per-character metadata is what the parent stage’s confidence heuristic and its CID-font / mojibake diagnostic actually read, and that same detail powers the coordinate anchoring in Extracting Coverage Limits from Scanned Policy PDFs. Decisively, pdfplumber’s MIT license lets you ship an extraction agent on-premises to a carrier, or embed it in a closed-source product, without the AGPL source-disclosure obligation PyMuPDF would trigger. Many teams run both: pdfplumber as the auditable default, PyMuPDF behind a feature flag for the throughput-critical batch lane. Whichever wins, hand the normalized values on to Field Mapping Strategies, and note that neither engine reconstructs dense ruled grids as well as a dedicated table parser — for premium and loss-run matrices, route the page to Camelot and settle its own lattice-versus-stream decision instead.
Compliance & Audit Note
Permalink to "Compliance & Audit Note"The engine choice is itself an auditable fact. Because a regulator examining a disputed Coverage A limit must be able to reconstruct exactly how the value was produced, persist the extraction engine name and its pinned version alongside the bounding box, the confidence signal, and the loss-date snapshot id in the same append-only audit record the parent stage already emits. An MIT-versus-AGPL determination is a governance decision your legal team signs off on once; recording which engine ran each extraction means a later license change or a swap to PyMuPDF for throughput never silently rewrites the provenance of historical records. The extracted values still reconcile against canonical definitions in Policy Schema Design and cross a tier boundary only under the logged transitions defined by Data Boundary Enforcement, regardless of which library read the bytes.
Troubleshooting Checklist
Permalink to "Troubleshooting Checklist"- PyMuPDF boxes select the wrong region. Symptom: a clip that works in pdfplumber returns neighbouring text under
fitz. Cause: the two libraries agree on a top-left origin, but a carrier page with a non-zero/Rotatevalue is handled differently. Fix: normalize rotation first, as in handling rotated pages, before applying either engine’s bounds. - pdfplumber batch is too slow at renewal peak. Symptom: the worker pool saturates and latency cascades into adjudication. Cause: pure-Python parsing cannot keep up with the surge. Fix: move the clean digital-origin lane to PyMuPDF and reserve pdfplumber for documents that need its per-char confidence signal, rather than raising worker counts blindly.
- AGPL flagged in a distribution audit. Symptom: a compliance scan blocks a release that bundles
fitz. Cause: PyMuPDF’s AGPL v3 obligation applies to distributed software. Fix: either purchase the commercial license or fall back to MIT-licensed pdfplumber for the shipped artifact; make the choice in the manifest, not per document. - Confidence scoring degrades after switching to PyMuPDF. Symptom: the triage tier loses its numeric gate. Cause: the word/span model does not expose the per-character density pdfplumber’s heuristic consumed. Fix: recompute confidence from PyMuPDF’s
get_text("dict")span sizes, or keep pdfplumber on the confidence-critical path. - Currency values drift by a cent. Symptom: a limit differs from the source by rounding noise. Cause: a
floatleaked into the parse. Fix: build theDecimaldirectly from the cleaned string in both adapters and never route a monetary value throughfloat.
Related
Permalink to "Related"- PDF Text Extraction with pdfplumber — the parent stage both engines plug into
- Extracting Coverage Limits from Scanned Policy PDFs — where pdfplumber’s coordinate fidelity earns its keep
- Camelot Lattice vs Stream Mode for Declarations Tables — the parallel decision for ruled grids neither engine parses well
- OCR Integration & Sync — the optical fallback PyMuPDF’s fast rasterizer feeds
- Field Mapping Strategies — normalizing whichever engine’s output into canonical ACORD identifiers