pdfplumber vs PyMuPDF vs Camelot: Choosing a PDF Table Extractor for CMMS Work Orders

Every team building the PDF parsing with Python stage of a maintenance pipeline eventually hits the same fork in the road: which library actually pulls a clean row of asset_tag, failure_code, and quantity values out of a vendor PDF at scale? The three realistic candidates in the Python ecosystem — pdfplumber, PyMuPDF (also imported as fitz), and Camelot — solve overlapping but distinct problems, and picking the wrong one produces exactly the fragmented-column failures already documented in extracting tables from PDF work orders using pdfplumber. This guide is not a bug fix; it is the decision you should make before you write that extractor, so the work order ingestion and parsing stage starts with the right tool for the PDF layouts your vendors actually send.

The stakes are concrete. A field service report with hairline-ruled grid lines behaves nothing like an ERP-exported invoice that separates columns with whitespace alone, and a scanned technician handwriting sheet behaves like neither. Choosing on vibes — or defaulting to whichever library a tutorial used — means re-writing the extraction layer six months in, once volume or layout diversity exposes the mismatch. This page compares the three libraries against the criteria that actually matter for CMMS ingestion, then implements the identical extraction task three times so the trade-offs are visible in code, not just in a table.

Decision Criteria

Before comparing libraries, fix the axes that matter for maintenance-document extraction specifically. A general “which PDF library is best” comparison is the wrong frame — the right frame is which library matches the table geometry and throughput your intake pipeline actually sees.

  • Ruled-line vs. whitespace tables. Does the source PDF draw visible vector lines between columns and rows (an ERP-generated invoice, a CMMS-exported service ticket), or does it rely purely on column alignment and spacing (a copy-pasted spreadsheet dropped into a report template)? This single factor eliminates more candidates than any other.
  • Extraction accuracy on maintenance layouts. Multi-line task descriptions, merged header cells, and inconsistent column widths are the norm in vendor service reports, not the exception. A library’s default settings matter less than how much calibration it needs to get from “mostly right” to “field-mapping-validator passes.”
  • Speed and throughput. A single ad-hoc PDF and a nightly batch of four thousand vendor invoices have completely different tolerance for per-document parsing time. Some libraries trade thoroughness for milliseconds; others do the opposite.
  • Memory footprint. Rendering every page to evaluate line geometry costs more RAM than walking a text-object stream. This matters once extraction runs inside a worker fleet processing documents concurrently rather than a notebook processing one file.
  • Scanned document and OCR support. None of the three libraries in this comparison performs OCR natively on image-only scans. What differs is how cleanly each one detects “this page has no extractable text” so you can branch to an OCR pre-stage instead of silently returning empty rows.
  • Licensing and dependency weight. Pure-Python dependencies install anywhere; libraries with native binary components or an external interpreter dependency add operational surface area — container image size, build-time requirements, and a second thing that can fail in production.
  • Maintenance and API stability. A library with an actively maintained release cadence and a stable public API is a safer long-term bet for a pipeline you intend to run unattended for years, not just for one migration project.

Comparison Matrix

The three libraries land in genuinely different places on every axis above. The diagram gives the shape of the trade-off at a glance; the table below it fills in the specifics.

Three-way comparison of pdfplumber, PyMuPDF, and Camelot for CMMS table extraction A matrix with three library columns — pdfplumber, PyMuPDF also known as fitz, and Camelot — against six rows: ruled-line tables, borderless whitespace tables, throughput at scale, memory footprint, scanned document handling, and dependency weight. pdfplumber is moderate on ruled tables, strong on borderless tables, moderate throughput, moderate memory, no OCR, and pure Python dependencies. PyMuPDF is strong on ruled tables, moderate on borderless tables, the fastest throughput, the lowest memory footprint, no OCR, and a compact native dependency. Camelot is the strongest on ruled tables using its lattice mode, weak on borderless tables even with its stream mode, the slowest throughput, the highest memory footprint because it renders pages as images for line detection, no OCR, and the heaviest dependency chain because it requires Ghostscript. Same extraction task, three libraries, six criteria pdfplumber PyMuPDF (fitz) Camelot Ruled-line tables Moderate Strong Strongest (lattice) Borderless / whitespace tables Strong Moderate Weak (stream) Throughput at scale Moderate Fastest Slowest Memory footprint Moderate Lowest Highest (renders pages) Scanned document handling No native OCR No native OCR No native OCR Dependency weight Pure Python Compact native Heaviest (+ Ghostscript) Reading the matrix pdfplumber wins when column edges are implied by whitespace, not vector lines — the common case for copy-pasted or template-exported vendor reports. PyMuPDF wins on raw speed and memory, and doubles as the fastest way to pull plain text or embedded images when the extraction task is broader than one table. Camelot wins decisively when the PDF draws real ruled grid lines and accuracy matters more than speed.
Criterion pdfplumber PyMuPDF (fitz) Camelot
Ruled-line tables Moderate — works but needs table_settings tuning Strong — structural table detection built in Strongest — flavor="lattice" is purpose-built for this
Borderless / whitespace tables Strong — vertical_strategy="text" infers edges from alignment Moderate — heuristic detection can misjudge implicit columns Weak — flavor="stream" guesses from whitespace and is fragile
Extraction accuracy on multi-line cells Good once snap_tolerance and join_tolerance are calibrated Good, generally needs less manual tuning Good on lattice tables, poor on stream tables with wrapped text
Speed at high volume Moderate — pure-Python geometry pass per page Fastest — native C library under a thin Python layer Slowest — renders each page as an image to detect lines
Memory footprint Moderate Lowest Highest — image rendering per page adds up fast
Scanned / image-only PDFs No native OCR — branch to an OCR pre-stage No native OCR — branch to an OCR pre-stage No native OCR — branch to an OCR pre-stage
Licensing / dependencies Pure Python, no external binary Native extension, no external interpreter dependency Requires Ghostscript (and opencv-python for lattice mode)
Also does raw text / image extraction Text extraction, yes; image extraction limited Excellent — fast raw text, images, and metadata alongside tables No — table extraction only

Hands-on: Extracting the Same Work Order Table Three Ways

To make the trade-offs concrete, here is the identical task solved with each library: a vendor PDF containing one ruled table per page with columns WO_ID, Asset_Tag, Task_Description, Labor_Hrs, Status — the same schema used in extracting tables from PDF work orders using pdfplumber. Each function takes a file path and returns a list of row lists, so the outputs are directly comparable.

pdfplumber

pdfplumber extracts tables by inferring column and row edges from either vector lines or text alignment, controlled per call through table_settings. For a borderless vendor export, switching both strategies to "text" is usually the single change that fixes a fragmented extraction.

import pdfplumber


def extract_with_pdfplumber(path: str) -> list:
    """Extract work-order rows using whitespace-aware column detection."""
    rows = []
    with pdfplumber.open(path) as pdf:
        for page in pdf.pages:
            table = page.extract_table(table_settings={
                "vertical_strategy": "text",
                "horizontal_strategy": "text",
                "snap_tolerance": 4,
                "join_tolerance": 3,
                "intersection_tolerance": 5,
            })
            if table:
                rows.extend(row for row in table if any(row))
    return rows

The "text" strategy costs nothing when the source PDF does have real ruled lines — it degrades gracefully — but it is the setting that rescues borderless layouts the "lines" default cannot see.

PyMuPDF (fitz)

PyMuPDF is imported as fitz for historical reasons, and its page.find_tables() method returns structural table objects directly, without a separate settings dictionary to tune in most cases. It is the fastest of the three by a wide margin because it walks the PDF’s internal text and graphics objects natively rather than re-deriving geometry in Python.

import fitz  # PyMuPDF


def extract_with_pymupdf(path: str) -> list:
    """Extract work-order rows using PyMuPDF's structural table finder."""
    rows = []
    doc = fitz.open(path)
    try:
        for page in doc:
            found = page.find_tables()
            for table in found.tables:
                extracted = table.extract()
                rows.extend(row for row in extracted if any(row))
    finally:
        doc.close()
    return rows

Because PyMuPDF also exposes page.get_text() and page.get_images() on the same document handle, it is the pragmatic choice when a single worker needs raw body text, embedded technician photos, and a table from one document — three extraction concerns other libraries would need separate passes for.

Camelot

Camelot wraps two distinct algorithms behind one API: flavor="lattice" detects ruled vector lines (the strongest option in this whole comparison when lines genuinely exist), and flavor="stream" falls back to whitespace heuristics that are noticeably less reliable than pdfplumber’s equivalent. Camelot renders each page to evaluate line geometry, which is why it depends on Ghostscript and is the slowest and most memory-hungry of the three.

import camelot


def extract_with_camelot(path: str) -> list:
    """Extract work-order rows using Camelot's lattice (ruled-line) mode."""
    rows = []
    tables = camelot.read_pdf(path, pages="all", flavor="lattice", line_scale=40)
    for table in tables:
        for row in table.df.values.tolist():
            if any(cell.strip() for cell in row):
                rows.append(row)
    return rows

line_scale controls how aggressively Camelot detects thin or broken grid lines — vendor PDFs with hairline (sub-1pt) rules often need this raised from its default before lattice mode finds every row.

Recommendation by Scenario

There is no universal winner; the right library is a function of what the source document looks like and how many of them you process per run.

  • Ruled tables from ERP or CMMS-exported PDFs — reach for Camelot in lattice mode first. When the source genuinely draws grid lines, lattice mode is the most accurate of the three at recovering exact cell boundaries, and the speed cost is tolerable for lower-volume, high-accuracy documents like signed service completion reports.
  • Borderless or positionally-laid-out tables — use pdfplumber with vertical_strategy="text". This is the majority case for copy-pasted vendor invoices and templated reports where no vector lines exist at all, and it is the library this pipeline already standardizes on for that reason.
  • High-volume batches or when you also need raw text and images — use PyMuPDF. If a nightly job processes thousands of documents, or the extraction task spans more than tables (body text, embedded photos, page metadata), PyMuPDF’s speed and broader feature surface make it the operationally cheaper choice even if its table-detection heuristics need occasional tuning.
  • Scanned or image-only documents — none of the three helps directly. Route the document to an OCR pre-stage first (a dedicated OCR engine producing a text or searchable-PDF layer), and only then apply whichever of the three libraries above matches the OCR output’s table geometry.
  • Mixed vendor population — most real intake queues see all of the above. A pragmatic pipeline classifies each incoming PDF’s layout family first (ruled vs. borderless vs. scanned) and dispatches to the matching extractor rather than forcing one library to handle every shape.

Mapping Extracted Rows into the Canonical WorkOrderPayload

Whichever library performs the extraction, every row must land in the same canonical shape before it reaches routing, so downstream stages never need to know which extractor produced it. The mapping step is where SLA fields get populated and where the work order schema standards validator gets its input.

from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional


class Priority(str, Enum):
    CRITICAL = "critical"
    HIGH = "high"
    STANDARD = "standard"
    PLANNED = "planned"


@dataclass
class WorkOrderPayload:
    """Canonical CMMS work order — SLA fields are mandatory site-wide."""
    work_order_id: str
    asset_id: str
    part_skus: List[str]
    required_quantities: Dict[str, int]
    location_id: str
    priority: Priority = Priority.STANDARD
    requested_completion: Optional[datetime] = None
    escalation_tier: int = 0
    status: str = "open"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


def row_to_payload(row: list, location_id: str) -> WorkOrderPayload:
    """Map one extracted table row [WO_ID, Asset_Tag, Task_Description, Labor_Hrs, Status]
    into the canonical payload, regardless of which library produced the row."""
    wo_id, asset_tag, _description, _labor_hrs, status = row
    escalated_status = str(status).strip().lower()
    is_urgent = escalated_status in {"urgent", "down", "critical"}
    return WorkOrderPayload(
        work_order_id=str(wo_id).strip(),
        asset_id=str(asset_tag).strip(),
        part_skus=[],
        required_quantities={},
        location_id=location_id,
        priority=Priority.CRITICAL if is_urgent else Priority.STANDARD,
        escalation_tier=1 if is_urgent else 0,
        status="open",
    )


def rows_to_payloads(rows: list, location_id: str) -> List[WorkOrderPayload]:
    return [row_to_payload(row, location_id) for row in rows if len(row) == 5]

Because row_to_payload only depends on the row’s positional shape, it works identically whether rows came from extract_with_pdfplumber, extract_with_pymupdf, or extract_with_camelot — the library choice is isolated to the extraction function, never leaking into the mapping or routing logic. That isolation is what lets a team swap extractors later (say, moving one vendor’s documents from pdfplumber to Camelot after their template adds ruled lines) without touching anything downstream, including how batches feed into async batch processing.

Decision Checklist

Frequently Asked Questions

Can I just run all three libraries and keep whichever result looks best?

You can as a one-time layout audit, but not as a production strategy — running three extraction libraries per document multiplies compute cost and gives you no deterministic way to select a winner without a human reviewing output. Classify the document’s layout family once, route it to the matching library, and reserve a multi-library comparison for onboarding a new vendor template.

Does PyMuPDF’s table support match dedicated table libraries in accuracy?

PyMuPDF’s find_tables() is newer than pdfplumber’s and Camelot’s table logic and handles common ruled and semi-ruled layouts well, but it has fewer tuning knobs for edge cases like inconsistent multi-line wrapping. Treat it as the throughput-optimized choice, not the maximum-accuracy choice, and fall back to pdfplumber or Camelot when a specific vendor template misparses under it.

Is Camelot worth the Ghostscript dependency for a small number of documents?

Usually yes if those documents are genuinely ruled and accuracy matters more than infrastructure simplicity — a signed compliance report or an ERP invoice with real grid lines is exactly what lattice mode is built for. If the volume is small and the layout is borderless, pdfplumber avoids the extra system dependency entirely and gets equivalent or better results.

What happens when a vendor changes their PDF template mid-year?

Because the mapping into WorkOrderPayload only depends on the row’s positional shape, a template change that alters column order or adds ruled lines only requires updating which extraction function handles that vendor’s documents — not rewriting the schema-mapping or routing logic. Track template versions per vendor and re-run your layout classification whenever an extraction confidence score drops unexpectedly.

This comparison sits inside PDF parsing with Python alongside the calibration walkthrough in extracting tables from PDF work orders using pdfplumber; once rows are mapped, keep the payload aligned with work order schema standards, and route high-volume extraction jobs through async batch processing rather than a synchronous loop. For the broader intake picture, see work order ingestion and parsing pipelines.

Part of: PDF Parsing with Python.