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.

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.

Decision matrix comparing pdfplumber and PyMuPDF across five criteria A two-column decision matrix scores pdfplumber against PyMuPDF on five criteria for carrier declarations extraction. On throughput and memory PyMuPDF wins because its MuPDF C core parses far faster and holds a smaller resident set. On character-level coordinate fidelity pdfplumber wins because it exposes per-character font, size, and stroke metadata that confidence heuristics and CID-font diagnostics rely on. Table access is close, with pdfplumber's mature extract_tables against PyMuPDF's newer find_tables. On licensing pdfplumber wins decisively: it is MIT, while PyMuPDF is AGPL v3 or a paid commercial license, which can veto it for on-premises or closed-source distribution. The verdict band concludes: choose PyMuPDF for high-volume throughput when AGPL is acceptable, and pdfplumber when coordinate fidelity or permissive licensing dominates. pdfplumber PyMuPDF (fitz) criterion Throughput pages / second Pure Python · pdfminer.six slower — the batch bottleneck MuPDF C core — 10-40x faster wins under surge load Memory RSS per worker char objects held per page heavier on 90-page binders streams text — smaller RSS page.get_text is incremental Coordinate fidelity per-char metadata char font · size · stroke drives confidence + CID checks word / span model coarser, faster, less metadata Table access built-in reconstruction extract_tables — mature line + text strategies find_tables — newer (1.23+) fast, less battle-tested Licensing distribution posture MIT — permissive ships anywhere, no obligation AGPL v3 or paid commercial can veto on-prem / closed-source Verdict PyMuPDF when throughput dominates and AGPL is acceptable · pdfplumber when per-char fidelity or permissive licensing wins
Five criteria decide the pick; only throughput, fidelity, and licensing usually settle it for a claims pipeline.

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 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)

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

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.

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.

  • 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 /Rotate value 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 float leaked into the parse. Fix: build the Decimal directly from the cleaned string in both adapters and never route a monetary value through float.