pdfplumber vs camelot vs tabula for customs invoice extraction
Choosing the wrong table extractor is the single most expensive mistake in a customs document pipeline: it silently drops line items, merges two SKUs into one row, or shifts a quantity into a price column, and the corruption only surfaces when CBP questions an entered value weeks later. This page compares the three tools Python teams actually reach for — pdfplumber, camelot (in both its lattice and stream flavors), and tabula-py — for pulling structured line-item tables out of commercial invoices, and gives you a concrete rule for picking one per document class. The short version: ruled PDFs with visible cell borders favor camelot lattice; borderless exporter output favors pdfplumber word-clustering; and tabula-py is the pragmatic fallback when a Java toolchain already exists and the invoices resemble clean spreadsheets. Everything below assumes the extracted rows feed the same downstream contract used in Extracting line items from commercial invoices with pdfplumber.
Prerequisites
Confirm the following before benchmarking any of the three libraries against your invoice corpus:
- Python 3.10+ with
Decimalused for every monetary and quantity field. Table extractors return strings; the moment you cast a price you useDecimal, neverfloat, because ISO 4217 minor-unit rounding is not representable in binary floating point and duty math downstream depends on exact cents. - A representative, labeled sample set. Pull at least 30 invoices spanning your real exporters and split them by structure: ruled (visible cell grid lines), unruled (whitespace-aligned columns), and hybrid. Accuracy claims mean nothing until measured against your own layouts.
- System dependencies for each contender.
camelotneeds Ghostscript plus either OpenCV or the bundled image backend;tabula-pyneeds a Java 8+ runtime because it shells out to the Tabula JAR;pdfplumberis pure Python on top ofpdfminer.sixwith no native or JVM dependency. Provision these in the container image before you start, not during a benchmark. - A born-digital vs. scanned split. None of these three run OCR. A scanned or photographed invoice has no text layer, so all three return empty tables — those documents route to the OCR path described in OCR Drift Correction & Validation before extraction is even attempted.
- A defined target schema. Decide the exact columns you require (description, HTS candidate, country of origin, quantity, unit price, line total) so extraction quality is scored against a fixed contract rather than eyeballed.
Implementation
The harness below runs all three extractors over the same PDF and normalizes each into a common list[InvoiceRow], so accuracy and timing are measured on identical inputs. Each adapter isolates the library-specific call; the shared normalizer enforces the Decimal contract regardless of which engine produced the cell text.
import logging
import time
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Optional, Callable
import pdfplumber
import camelot
import tabula
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("invoice_extract_bench")
@dataclass(frozen=True)
class InvoiceRow:
description: str
hts_candidate: Optional[str]
origin: Optional[str]
quantity: Decimal
unit_price: Decimal
line_total: Decimal
def _to_decimal(raw: str) -> Decimal:
"""Cell text -> Decimal. Strips grouping/currency noise; never uses float."""
cleaned = (
raw.replace(",", "")
.replace("USD", "")
.replace("\n", " ")
.strip()
)
try:
return Decimal(cleaned) if cleaned else Decimal("0")
except InvalidOperation:
return Decimal("0")
def _normalize(cells: list[list[str]]) -> list[InvoiceRow]:
"""Map a raw grid of strings onto the fixed target schema."""
rows: list[InvoiceRow] = []
for c in cells:
if len(c) < 6 or not c[0].strip():
continue # header, footer, or ragged row
rows.append(
InvoiceRow(
description=c[0].strip(),
hts_candidate=(c[1].strip() or None),
origin=(c[2].strip() or None),
quantity=_to_decimal(c[3]),
unit_price=_to_decimal(c[4]),
line_total=_to_decimal(c[5]),
)
)
return rows
def extract_pdfplumber(path: str, page: int = 0) -> list[list[str]]:
with pdfplumber.open(path) as pdf:
table = pdf.pages[page].extract_table(
{"vertical_strategy": "text", "horizontal_strategy": "text"}
)
return table or []
def extract_camelot(path: str, flavor: str = "lattice") -> list[list[str]]:
tables = camelot.read_pdf(path, pages="1", flavor=flavor)
if tables.n == 0:
return []
return tables[0].df.values.tolist()
def extract_tabula(path: str) -> list[list[str]]:
dfs = tabula.read_pdf(path, pages="1", lattice=True, pandas_options={"dtype": str})
if not dfs:
return []
return [list(dfs[0].columns)] + dfs[0].fillna("").values.tolist()
def benchmark(path: str, engine: str) -> tuple[list[InvoiceRow], float]:
adapters: dict[str, Callable[[str], list[list[str]]]] = {
"pdfplumber": extract_pdfplumber,
"camelot_lattice": lambda p: extract_camelot(p, "lattice"),
"camelot_stream": lambda p: extract_camelot(p, "stream"),
"tabula": extract_tabula,
}
start = time.perf_counter()
grid = adapters[engine](path)
rows = _normalize(grid)
elapsed = time.perf_counter() - start
logger.info("engine=%s rows=%d elapsed=%.3fs", engine, len(rows), elapsed)
return rows, elapsed
The value of the harness is that it forces every engine through one normalizer and one Decimal cast. Whichever tool you keep, the row contract handed downstream is identical, so swapping engines per document class never changes what Handling multi-currency invoices with Babel or the entry-summary assembler receives.
Verification steps
Run this checklist against your labeled sample set before committing to an engine per document class:
- Row-count parity. For each invoice, assert the extracted row count equals the human-counted line-item count.
camelot streamon a ruled PDF andpdfplumberwith the wrong strategy both tend to merge adjacent rows — a shortfall here is the earliest, cheapest signal of a bad match. - Column-shift detection. Confirm every
quantityparses as a whole or decimal number and everyunit_priceis positive. A description bleeding into the quantity column, or a price landing under quantity, is the classic borderless-table failure and shows up asDecimal("0")from_to_decimal. - Line-total reconciliation. For each row, assert
quantity * unit_priceequals the extractedline_totalto the cent under exactDecimalarithmetic. Mismatches localize the exact rows an engine mis-split without any manual inspection. - Dependency smoke test. In the target container, run each adapter once on a trivial PDF.
tabulafails loudly if Java is absent;camelot latticefails if Ghostscript is missing. Catch these in CI, never in a production batch. - Timing budget. Record wall-clock per engine per page.
camelot latticeruns an OpenCV line-detection pass and is often several times slower thanpdfplumber; if you process high volume, factor that into the async fan-out described in Async Batch Processing for High Volume. - Empty-table guard. Feed a scanned (image-only) invoice. All three must return zero rows, and your orchestration must route that document to OCR rather than filing an empty entry.
Edge cases & gotchas
- Multi-page line-item tables. Long invoices wrap the item table across pages, repeating the header on each.
camelotandtabulatreat pages independently, so you must concatenate their per-page frames and drop repeated header rows;pdfplumberlets you iteratepdf.pagesand stitch before normalizing. Failing to stitch silently truncates the entry at the page break. - Merged and spanned cells. A description that spans two visual rows, or a single “carton” note spanning several SKUs, breaks
camelot latticecell inference and producesNonewhere a value belongs. Detect ragged rows in_normalize(thelen(c) < 6guard) and quarantine rather than guess. - Thousands and decimal separators. European exporters write
1.234,56where US invoices write1,234.56. A naivereplace(",", "")corrupts the European form into1.23456. Do currency-aware parsing before theDecimalcast — that normalization is the entire subject of Handling multi-currency invoices with Babel, and it belongs upstream of duty math. - Rotated or landscape pages. Scanner and exporter output sometimes flags a page as rotated.
pdfplumberrespects the page rotation matrix;tabulaandcamelotmay read coordinates off the pre-rotation box and return scrambled columns. Normalize page rotation before extraction. - Java version drift.
tabula-pyis only as stable as the JVM on the host. A base-image upgrade that bumps Java can change tabula’s output whitespace handling. Pin the runtime and treat the extractor version and Java version together as one reproducibility unit. - Ghostscript licensing and size.
camelot’s Ghostscript dependency adds install weight and a licensing consideration for some deployments. If neither a JVM nor Ghostscript is acceptable in your image,pdfplumberis the only pure-Python option and should be your default, with the others reserved for document classes it measurably loses on. - Downstream stability over peak accuracy. The rows produced here become entered values on a CBP 7501; a mixed pipeline that routes each document class to its best engine but always emits the same
InvoiceRowcontract is safer than chasing a marginal accuracy gain with per-invoice tuning. Feed the stable contract into Entry Summary Filing Schemas.