pdfplumber vs Camelot for structural report tables
Every tower inspection portfolio eventually produces the same argument: the extractor that worked perfectly on last quarter’s vendor template returns garbage on this quarter’s. Usually the culprit is not a bug — it is that the report’s table geometry changed and the library you picked was never built for that geometry. This page is a decision guide, not a tutorial for one tool: it compares pdfplumber and Camelot, the two most common Python options inside the Table Extraction Strategies work, head-to-head on the table shapes that actually show up in structural inspection PDFs — ruled specification grids, borderless load-capacity blocks, scanned legacy forms, and merged-header layouts. By the end you will know which library to reach for on a given report, and you will have a runnable harness that runs both over the same page and hashes each result so you can prove, rather than guess, whether they agree.
Neither library is strictly better. pdfplumber is a thin, dependency-light layer over pdfminer.six that exposes character and line coordinates directly — it is fast and it is the tool this site defaults to in PDFplumber Extraction Workflows. Camelot wraps pdfminer for text and calls out to Ghostscript and OpenCV for image processing, and it ships two distinct extraction algorithms — lattice and stream — each tuned for a different table shape. Picking wrong costs you either silently wrong rows or an extraction pipeline that needs a systems package you cannot install in a locked-down environment.
Prerequisites & Context
Before running anything below, have this in place:
- Python 3.10+. Both libraries support it; nothing here is version-specific beyond that floor.
pip install pdfplumberfor the pure-Python path. It has no system dependencies beyondpdfminer.six, which it installs automatically.pip install "camelot-py[cv]"plus Ghostscript. Camelot’slatticeandstreamflavors both needopencv-python(pulled in by the[cv]extra) and a working Ghostscript binary on the systemPATH— Camelot shells out to it to rasterize pages for line detection. This is the single biggest operational difference between the two libraries: a container image that haspip install-only dependencies will build cleanly withpdfplumberand fail at import time with Camelot until Ghostscript is added to the base image.- A native text layer. Both libraries read the PDF’s embedded text and vector graphics; neither one performs optical character recognition. A 300-DPI scan of a legacy climb sheet with no text layer defeats both equally, and that report needs to go through OCR for Legacy Inspection Forms first.
- A site identifier per file, keyed as
TWR-####, so extracted rows cross-reference the correct antenna structure downstream — this page usesTWR-8842throughout.
If your report needs merged-header normalization after extraction — not just getting the raw grid out — that step is covered separately in Normalizing merged and multi-header tables from inspection reports. This page stops at getting a clean row-and-column grid out of the PDF; that one starts from the grid and reconciles spanning cells into a flat schema.
How Each Library Sees a Table
The two libraries do not just implement different heuristics — they look at fundamentally different evidence in the PDF.
pdfplumber’s find_tables() builds a table structure from two candidate signal sets, controlled by its vertical_strategy and horizontal_strategy settings: "lines", which looks for actual drawn rule lines in the PDF’s vector graphics, and "text", which infers column and row boundaries from gaps between clusters of character positions when no lines exist. Because it operates directly on pdfminer’s character and line objects with no external process, a page either has enough visual structure for the heuristic to find cell boundaries or it does not — there is no separate algorithm to switch to.
Camelot instead exposes two entirely separate extraction engines you choose between explicitly. flavor="lattice" rasterizes the page and uses OpenCV to detect ruled lines directly from the image, then intersects horizontal and vertical lines to build a cell grid — it is precise when a table has real border rules and nearly blind when it does not. flavor="stream" ignores lines entirely and clusters text by whitespace gaps, similar in spirit to pdfplumber’s "text" strategy but implemented independently with its own gap-tuning parameters (row_tol, column_tol). Because Camelot always shells out to Ghostscript to convert the page for line detection, both of its flavors carry the same setup cost even though only lattice uses the rendered image for its actual boundary logic.
The practical consequence: if a report’s tables are drawn with visible grid lines, lattice and pdfplumber’s "lines" strategy are looking at nearly the same evidence and tend to agree closely. If the tables are whitespace-aligned with no rules — common in condensed inspection summaries where every printed rule line would waste vertical space — stream and pdfplumber’s "text" strategy are both guessing from gaps, and they guess differently often enough that neither should be trusted without a bounds check on the output.
Head-to-Head Comparison
The matrix below scores each engine on the table shapes and operational concerns that actually recur across a tower inspection portfolio — not a synthetic benchmark, but the failure modes that show up when TWR-8842’s flange spec sheet lands next to TWR-5107’s scanned 1998 climb log in the same batch job.
Figure: pdfplumber vs Camelot lattice vs Camelot stream, scored across the table shapes and operational concerns that recur in tower inspection PDFs.
Two rows deserve a callout beyond the badges. Scanned PDFs score Weak across the board deliberately — this is not a library weakness to fix by switching tools, it is a categorical limit. Both libraries parse PDF content streams; neither rasterizes-and-reads pixels the way an OCR engine does, so a report with no text layer returns empty results from all three, silently, unless you check for it. Dependency cost is where the decision often gets made before accuracy even enters the conversation: Camelot requires Ghostscript as a system binary, which many CI runners, serverless functions, and locked-down production containers do not have preinstalled, and which requires root or a custom base image to add. If your extraction job runs inside a constrained deployment target, pdfplumber’s zero-system-dependency footprint can outweigh a modest accuracy edge that lattice has on heavily ruled tables.
Complete Runnable Example
The harness below runs both libraries — where available — against the same PDF page, hashes each engine’s extracted rows with hashlib.sha256, and reports whether the two independent extraction paths agree on the data. Both import statements are guarded so the script still runs and produces a partial comparison in an environment where only one library is installed, which is itself a useful production signal: a deployment that silently drops from three engines to one due to a missing Ghostscript binary should be visible in the logs, not just in a shorter results list.
# pip install pdfplumber
# pip install "camelot-py[cv]" # also requires the Ghostscript system binary
import hashlib
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
try:
import pdfplumber
except ImportError:
pdfplumber = None
try:
import camelot
except ImportError:
camelot = None
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("table_engine_bench")
class TableEngineError(Exception):
"""Raised when a table engine is unavailable or returns no usable table."""
@dataclass
class EngineResult:
engine: str
site_id: str
page: int
row_count: int
col_count: int
digest: str
def _hash_rows(rows: List[List[Optional[str]]]) -> str:
canonical = json.dumps(rows, sort_keys=False, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def run_pdfplumber(site_id: str, pdf_path: Path, page_number: int) -> EngineResult:
if pdfplumber is None:
raise TableEngineError("pdfplumber is not installed")
with pdfplumber.open(pdf_path) as pdf:
page = pdf.pages[page_number - 1]
tables = page.find_tables()
if not tables:
raise TableEngineError(f"{site_id} p{page_number}: pdfplumber found no tables")
rows = tables[0].extract()
return EngineResult("pdfplumber", site_id, page_number, len(rows),
len(rows[0]) if rows else 0, _hash_rows(rows))
def run_camelot(site_id: str, pdf_path: Path, page_number: int, flavor: str) -> EngineResult:
if camelot is None:
raise TableEngineError("camelot-py is not installed")
tables = camelot.read_pdf(str(pdf_path), pages=str(page_number), flavor=flavor)
if not tables:
raise TableEngineError(f"{site_id} p{page_number}: camelot ({flavor}) found no tables")
rows = tables[0].df.values.tolist()
return EngineResult(f"camelot-{flavor}", site_id, page_number, len(rows),
len(rows[0]) if rows else 0, _hash_rows(rows))
def compare_engines(site_id: str, pdf_path: Path, page_number: int) -> List[EngineResult]:
results: List[EngineResult] = []
engine_calls = (
("pdfplumber", lambda: run_pdfplumber(site_id, pdf_path, page_number)),
("camelot-lattice", lambda: run_camelot(site_id, pdf_path, page_number, "lattice")),
("camelot-stream", lambda: run_camelot(site_id, pdf_path, page_number, "stream")),
)
for label, call in engine_calls:
try:
result = call()
results.append(result)
logger.info("ENGINE | %s | %s | rows=%d cols=%d | %s",
result.engine, site_id, result.row_count, result.col_count, result.digest[:12])
except TableEngineError as exc:
logger.warning("SKIP | %s | %s", label, exc)
return results
if __name__ == "__main__":
results = compare_engines("TWR-8842", Path("twr8842_load_capacity.pdf"), page_number=2)
digests = {r.engine: r.digest for r in results}
unique = len(set(digests.values()))
if len(digests) >= 2 and unique == 1:
logger.info("MATCH | %d engines produced an identical row-set digest", len(digests))
elif len(digests) >= 2:
logger.info("DIVERGE | engines disagree on extracted rows: %s",
{k: v[:8] for k, v in digests.items()})
print(f"TWR-8842: {len(results)} engine(s) ran | {digests}")
Verification & Expected Output
Against TWR-8842’s ruled load-capacity table on page 2 — a genuine bordered grid — pdfplumber and camelot-lattice typically produce matching row counts, and if the underlying cell text matches character-for-character, their digests agree:
2026-04-14 09:12:03 | INFO | ENGINE | pdfplumber | TWR-8842 | rows=6 cols=4 | 7c1e4a2f9b3d
2026-04-14 09:12:04 | INFO | ENGINE | camelot-lattice | TWR-8842 | rows=6 cols=4 | 7c1e4a2f9b3d
2026-04-14 09:12:05 | INFO | ENGINE | camelot-stream | TWR-8842 | rows=7 cols=4 | a819fe20cc41
2026-04-14 09:12:05 | INFO | DIVERGE | engines disagree on extracted rows: {'pdfplumber': '7c1e4a2f', 'camelot-lattice': '7c1e4a2f', 'camelot-stream': 'a819fe20'}
TWR-8842: 3 engine(s) ran | {'pdfplumber': '7c1e4a2f9b3d...', 'camelot-lattice': '7c1e4a2f9b3d...', 'camelot-stream': 'a819fe20cc41...'}
That output is exactly what the comparison matrix predicts: on a ruled table, the two line-aware paths (pdfplumber reading vector lines, camelot-lattice reading rasterized lines) converge on the same six rows, while stream’s whitespace-gap heuristic finds a phantom seventh row where a wrapped cell’s second line got clustered as its own row. To assert this in a test harness, do not assert exact digest equality between all three engines — assert it only between the two you expect to agree for a given table shape: assert result_by_engine["pdfplumber"].digest == result_by_engine["camelot-lattice"].digest on a known-ruled fixture is a meaningful regression check; the same assertion against camelot-stream is not, and treating a mismatch there as a bug will send you chasing a difference that is inherent to the algorithm, not a defect.
If every engine raises TableEngineError with a “found no tables” message, the page almost certainly has no extractable table structure at all — check page.chars for empty content, which is the signature of a scanned page with no text layer, before assuming a tuning problem.
Gotchas & Edge Cases
- Camelot’s Ghostscript dependency fails silently in some environments. If Ghostscript is missing,
camelot.read_pdf()can raise an unhelpful low-level subprocess error rather than a cleanImportError, well after the module import succeeds. Wrap the first real call in the sameTableEngineErrorhandling shown above and checksubprocess.run(["gs", "--version"])during environment setup rather than at extraction time, so a missing binary surfaces as a deployment-checklist failure instead of a mysterious first-run crash. streammode’s row and column tolerances need per-template tuning. Camelot’sstreamflavor exposesrow_tolandcolumn_tolparameters that default to values tuned on dense financial tables, not the sparser layout common in inspection summaries. A borderless table with generous cell padding can get its rows split or merged incorrectly until you widenrow_tol; always sanity-checkrow_countagainst a visual read of the source page rather than trusting the default tolerances on a new vendor template.- A table that “parses” with zero errors can still be wrong. Both libraries return a grid of strings with no built-in notion of whether the grid is semantically correct — a
streamextraction that merges two adjacent narrow columns into one produces valid-looking output that silently drops a field. Validate extracted row and column counts against an expected schema (a known bolt-spec table always has exactly 4 columns, for example) rather than treating “no exception raised” as proof of correctness.
FAQ
Which library should I default to for a new, unseen vendor template?
Start with pdfplumber. It has no system dependencies to provision, it is fast enough to run against a whole batch while you inspect output, and its "lines" and "text" strategies cover both ruled and borderless layouts reasonably well as a first pass. Reach for camelot-lattice specifically when a report has heavily ruled tables and pdfplumber is missing rows or misreading columns — that is the one scenario where Camelot’s rasterized line detection reliably outperforms pdfplumber’s vector-line reading. Reserve camelot-stream for borderless tables where pdfplumber’s text-gap heuristic is visibly wrong; do not adopt it as a default given its Ghostscript dependency and its documented tendency to split wrapped cells into extra rows.
Can I run both libraries together to increase confidence, or is that overkill?
Running both is reasonable exactly at the point where a table matters enough to justify the extra dependency and runtime cost — flange torque specifications and load-capacity tables that feed compliance decisions, for instance. The harness above is designed for that: when the digests from two line-aware paths agree, you have independent confirmation from two different codebases parsing the same PDF bytes differently, which is a stronger correctness signal than any single library’s confidence score. For high-volume, low-stakes tables, running one library and validating its output against an expected schema is usually the better cost tradeoff.
Why does camelot-stream sometimes report more rows than the table actually has?
stream mode clusters text into rows based on vertical whitespace gaps between lines of text, with no awareness of which lines belong to the same logical row. When a cell’s content wraps onto a second visual line — a long bolt description or a multi-word zoning note — and the gap above that wrapped line happens to exceed row_tol, stream treats it as the start of a new row, producing a “row” that is really just the tail of the previous one. Increasing row_tol for that template, or falling back to pdfplumber’s "lines" strategy if the table does have subtle rules, resolves it more reliably than filtering suspiciously short rows after the fact.
Related
- Up to the parent work: Table Extraction Strategies
- Sibling task: Normalizing merged and multi-header tables from inspection reports
- Related workflows: PDFplumber Extraction Workflows
- The full architecture: Automated Structural Report Parsing & Document Ingestion