pdfplumber vs Camelot vs PyMuPDF for Government Form Extraction
Within Metadata Extraction Techniques, the choice of PDF library is the decision that most quietly determines whether a batch of government forms extracts cleanly or fills the review queue — pdfplumber, Camelot, and PyMuPDF each win decisively on a different form type, and picking one library for the whole corpus is how records teams end up fighting the tool. This guide compares the three on the axes that matter for public-records automation — ruled versus borderless tables, dependence on an embedded text layer, and speed at batch scale — and shows how to route each document to the library that reads it best.
Scenario & Compliance Stakes
A FOIA or open-records pipeline never receives one kind of PDF. A single day’s intake mixes a born-digital state application form with clean ruled tables, a legacy grant report whose columns are aligned by whitespace with no cell borders at all, and a scanned permit that is an image wearing a .pdf extension with no text layer underneath. Each of those needs a different extraction strategy, and the failure mode of using the wrong tool is not a crash — it is a table that extracts with shifted columns, so a dollar amount lands in the wrong field and a disclosure is made against the wrong record.
The compliance stakes track the fields these tables hold: applicant names, filing dates, permit numbers, fee amounts, and department codes that drive the FOIA Request Taxonomy Design and the Department Routing Logic. A borderless table misread as one merged column can silently drop a row; a scanned form pushed through a text-layer parser returns empty, and if that empty result is treated as “no responsive content” the agency has effectively failed to search. Because the response clock under 5 U.S.C. § 552(a)(6)(A)(i) is short, the pipeline cannot afford to re-run a whole corpus through a second library after the first produces garbage — it has to route each document to the right tool the first time.
The three libraries are not ranked best-to-worst; they are specialized. The engineering task is to know which specialization each document needs and to encode that routing deterministically.
The Three Libraries at a Glance
pdfplumber exposes every character and line on a page with its coordinates. It does not “detect tables” so much as give you the raw geometry to reconstruct them, which makes it the most flexible option for borderless tables where you infer columns from x-positions rather than from drawn lines. That flexibility is also its cost: on a cleanly ruled table it does more work than a lattice detector needs to.
Camelot is purpose-built for tables and ships two flavors. Its lattice flavor detects cell boundaries from the actual ruling lines and is the strongest of the three on bordered government forms; its stream flavor infers columns from whitespace for borderless layouts. Both flavors require a real embedded text layer — Camelot reads text, it does not recognize it — so a scanned image PDF returns nothing.
PyMuPDF (imported as fitz) is the speed and breadth option. It reads text with layout, renders pages to images, and walks the document structure faster than the other two, but it carries no built-in table-reconstruction intelligence. Its decisive role in a records pipeline is throughput and rasterization: it is the fastest way to pull a text layer when one exists, and the fastest way to rasterize a scanned page so it can be handed to OCR.
The same comparison as a table, which is the form to keep in a runbook:
| Library | Ruled tables | Borderless tables | Needs text layer | Speed at scale |
|---|---|---|---|---|
| pdfplumber | Good — reconstruct from lines and coordinates | Positional — infer columns from x-positions | Yes | Moderate |
| Camelot | Strong — lattice flavor reads cell borders | Fair — stream flavor infers from whitespace | Yes | Slower |
| PyMuPDF | Basic — no table intelligence built in | Basic — text with layout only | Yes for text; can rasterize for OCR | Fastest |
Choosing by Document Type
The routing rule follows directly from the matrix. Bordered, born-digital forms — the state applications and permit templates drawn with visible cell rules — go to Camelot’s lattice flavor, which reads those borders more reliably than anything else here. Borderless, whitespace-aligned tables — many grant reports and older typed forms — go to pdfplumber, whose per-character coordinates let you group columns by x-position when there are no lines to detect. Scanned image PDFs with no text layer go to PyMuPDF first, not to extract the table but to rasterize each page fast, so the image can be handed to the OCR Processing Pipelines that produce a text layer before any table logic runs. And when a corpus is simply large and mostly text, PyMuPDF’s speed makes it the default reader, with Camelot or pdfplumber invoked only on the pages that actually contain tables.
A detail that reshapes the whole decision: Camelot and pdfplumber both require an embedded text layer, so the first question is never “which table library” but “does this PDF have text at all.” That check is cheap and belongs at the front of the router.
Detect whether a text layer exists (PyMuPDF)
PyMuPDF answers the gating question fastest. If a page returns effectively no text, it is a scan, and the whole document routes to rasterization and OCR rather than to a table parser that would return empty.
import logging
import fitz # PyMuPDF
logging.basicConfig(
format='{"ts":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s"}',
level=logging.INFO,
)
logger = logging.getLogger("form_router")
def has_text_layer(pdf_path: str, min_chars: int = 20) -> bool:
"""Return True if the document carries an extractable text layer."""
try:
with fitz.open(pdf_path) as doc:
# Sum text length across pages; a scan returns near-zero.
total = sum(len(page.get_text("text")) for page in doc)
except Exception as exc: # never let a corrupt PDF crash the batch
logger.error("open_failed path=%s err=%s", pdf_path, type(exc).__name__)
return False
logger.info("text_layer_probe path=%s chars=%d", pdf_path, total)
return total >= min_chars
Read a ruled, bordered form (Camelot lattice)
When the probe confirms a text layer and the form has drawn cell borders, Camelot’s lattice flavor is the strongest reader.
import camelot
def extract_ruled_tables(pdf_path: str, pages: str = "all"):
"""Extract bordered tables using Camelot's lattice flavor."""
try:
tables = camelot.read_pdf(pdf_path, flavor="lattice", pages=pages)
except Exception as exc: # missing text layer or Ghostscript failure
logger.error("camelot_failed path=%s err=%s", pdf_path, type(exc).__name__)
return []
logger.info("camelot_lattice path=%s tables=%d", pdf_path, tables.n)
# Return each table as a list-of-rows for schema validation downstream.
return [t.df.values.tolist() for t in tables]
Reconstruct a borderless table (pdfplumber)
When there are no cell borders to detect, pdfplumber’s coordinates let you infer the table structure positionally.
import pdfplumber
def extract_borderless_tables(pdf_path: str):
"""Reconstruct borderless tables from text positions with pdfplumber."""
rows = []
try:
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
# "text" strategy groups columns by x-position, no lines needed.
settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
for table in page.extract_tables(settings):
rows.extend(table)
except Exception as exc:
logger.error("pdfplumber_failed path=%s err=%s", pdf_path, type(exc).__name__)
return []
logger.info("pdfplumber_stream path=%s rows=%d", pdf_path, len(rows))
return rows
Whichever library reads the table, the extracted rows are not the finish line — they still pass through the strict schema validation the parent Metadata Extraction Techniques guide enforces, so a shifted column or an empty cell fails loudly at the boundary rather than contaminating the index.
Validation & Verification
Because the wrong library produces plausible-looking but wrong tables, correctness is asserted against known fixtures:
- Text-layer gating: feed a scanned image PDF to
has_text_layerand assert it returnsFalse, confirming that image-only documents route to OCR rather than to a table parser that would silently return nothing. - Column integrity: run a bordered fixture through Camelot lattice and a borderless fixture through pdfplumber, and assert both return the known row and column counts. A row-count mismatch on the borderless case is the signal that the positional strategy needs tuning.
- Cross-library agreement: on a born-digital form that both Camelot and pdfplumber can read, assert the two agree on the extracted values; a divergence flags a document whose structure is ambiguous and belongs in review.
- Failure isolation: feed a corrupt PDF to each reader and assert it logs and returns an empty result rather than raising, so one bad file never stalls a batch.
def test_scanned_pdf_has_no_text_layer(tmp_path):
# A rasterized-only PDF must route to OCR, not to a table parser.
assert has_text_layer("fixtures/scanned_permit.pdf") is False
At high volume the routing itself is wrapped in the bounded-concurrency discipline of Async Batch Processing, so the text-layer probe and the chosen reader run under a throttled worker pool rather than exhausting memory on a spike of large forms.
Troubleshooting & Edge Cases
- Empty result from Camelot on a “PDF” form. The document is a scan with no text layer, and Camelot read nothing. Diagnosis:
has_text_layerreturnsFalse. Fix: route to PyMuPDF rasterization and OCR before any table parsing — never treat the empty result as “no responsive content.” - Shifted columns in a borderless table. pdfplumber grouped two adjacent columns into one because their x-positions overlapped. Diagnosis: fewer columns than the fixture expects. Fix: tune the text strategy’s tolerance, or fall back to explicit x-position column boundaries derived from the header row.
- Lattice detects no tables on a visibly ruled form. The ruling lines are too thin or broken for border detection. Diagnosis:
tables.n == 0on a bordered document. Fix: increase Camelot’s line-scale, or fall back to pdfplumber positional reconstruction. - PyMuPDF returns text but garbled order. Reading order on a multi-column form is not always top-to-bottom. Fix: use block-level extraction and sort blocks by coordinate rather than trusting stream order.
- Speed regression at scale. Running Camelot on every page of a large mostly-text corpus is slow. Fix: gate table extraction on pages that actually contain tables, using PyMuPDF as the fast pre-pass to find them.
Compliance Verification Checklist
FAQ
Which library should be the default for a mixed government-form corpus?
Make PyMuPDF the front door and the default reader, but never the sole extractor. Its speed makes it the right tool to open every document, probe for a text layer, and pull plain text fast, and its rasterization is how scanned pages reach OCR. It has no built-in table intelligence, though, so the moment a page holds a real table you route to Camelot for bordered forms or pdfplumber for borderless ones. The default is not “one library for everything” — it is one library to triage, and two specialists it delegates tables to.
Why do Camelot and pdfplumber return nothing on some PDFs?
Because both read an embedded text layer rather than recognizing pixels, and a scanned form is an image with no text underneath. Handed a scan, they return empty not because the form is blank but because there is no text object to extract. That is exactly why the router probes for a text layer first: an empty result from a table parser on a scan must trigger rasterization and OCR, never be recorded as an absence of responsive content, which would amount to failing to search the record.
When is Camelot's lattice flavor worth the extra cost over pdfplumber?
When the form has genuine drawn cell borders. Lattice detects those ruling lines directly and reconstructs cells more reliably than positional inference, so on a bordered state application or permit template it produces cleaner tables with fewer column-merge errors. On a borderless, whitespace-aligned table it has no lines to work with and pdfplumber’s coordinate grouping is the better tool. The deciding factor is whether the table’s structure is drawn or merely implied by spacing.
Related
- Metadata Extraction Techniques — the parent capability this library comparison feeds field extraction into.
- Extracting Agency Codes from Scanned Document Headers — the header-level extractor that runs after these table readers isolate a form’s structure.
- OCR Processing Pipelines — where PyMuPDF-rasterized scans go to gain a text layer before table parsing.
- Async Batch Processing — wraps the router and readers in bounded concurrency for high-volume runs.
- Department Routing Logic — consumes the fields these libraries extract to route requests to the owning office.
← Back to all public records automation topics