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 Decimal used for every monetary and quantity field. Table extractors return strings; the moment you cast a price you use Decimal, never float, 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. camelot needs Ghostscript plus either OpenCV or the bundled image backend; tabula-py needs a Java 8+ runtime because it shells out to the Tabula JAR; pdfplumber is pure Python on top of pdfminer.six with 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.

Comparison matrix of pdfplumber, camelot, and tabula-py A three-column comparison matrix scoring the three extractors across five criteria. Columns are pdfplumber, camelot (lattice and stream), and tabula-py. Rows: best table type — pdfplumber suits unruled whitespace tables, camelot lattice suits ruled bordered tables while stream suits unruled, tabula suits clean ruled spreadsheet-like tables. Runtime dependency — pdfplumber is pure Python with none, camelot needs Ghostscript plus OpenCV, tabula needs a Java runtime. Relative speed — pdfplumber is fast, camelot is slow especially lattice, tabula is moderate. Border reliance — pdfplumber none, camelot lattice requires visible rules while stream infers them, tabula lattice mode requires rules. Best fit — pdfplumber for borderless exporter PDFs, camelot for ruled multi-line-item invoices, tabula when a Java stack already exists. The matrix concludes that engine choice should be driven per document class rather than picking one tool for all invoices. criterion pdfplumber pure Python camelot lattice / stream tabula-py Java JAR best table type unruled, whitespace aligned columns ruled = lattice unruled = stream clean ruled, spreadsheet-like dependency none (pdfminer) Ghostscript + OpenCV Java 8+ runtime relative speed fast slow (lattice CV pass) moderate (JVM startup) border reliance none lattice needs rules, stream infers them lattice needs rules pick it when borderless exporter PDFs, no JVM wanted ruled multi-line-item invoices, tight cells a Java stack already exists Route each document class to its best engine — do not standardize on one tool.
Score each extractor against your own labeled corpus; the border style of the invoice, not brand preference, should decide the engine per document class.

Verification steps

Run this checklist against your labeled sample set before committing to an engine per document class:

  1. Row-count parity. For each invoice, assert the extracted row count equals the human-counted line-item count. camelot stream on a ruled PDF and pdfplumber with the wrong strategy both tend to merge adjacent rows — a shortfall here is the earliest, cheapest signal of a bad match.
  2. Column-shift detection. Confirm every quantity parses as a whole or decimal number and every unit_price is positive. A description bleeding into the quantity column, or a price landing under quantity, is the classic borderless-table failure and shows up as Decimal("0") from _to_decimal.
  3. Line-total reconciliation. For each row, assert quantity * unit_price equals the extracted line_total to the cent under exact Decimal arithmetic. Mismatches localize the exact rows an engine mis-split without any manual inspection.
  4. Dependency smoke test. In the target container, run each adapter once on a trivial PDF. tabula fails loudly if Java is absent; camelot lattice fails if Ghostscript is missing. Catch these in CI, never in a production batch.
  5. Timing budget. Record wall-clock per engine per page. camelot lattice runs an OpenCV line-detection pass and is often several times slower than pdfplumber; if you process high volume, factor that into the async fan-out described in Async Batch Processing for High Volume.
  6. 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. camelot and tabula treat pages independently, so you must concatenate their per-page frames and drop repeated header rows; pdfplumber lets you iterate pdf.pages and 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 lattice cell inference and produces None where a value belongs. Detect ragged rows in _normalize (the len(c) < 6 guard) and quarantine rather than guess.
  • Thousands and decimal separators. European exporters write 1.234,56 where US invoices write 1,234.56. A naive replace(",", "") corrupts the European form into 1.23456. Do currency-aware parsing before the Decimal cast — 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. pdfplumber respects the page rotation matrix; tabula and camelot may read coordinates off the pre-rotation box and return scrambled columns. Normalize page rotation before extraction.
  • Java version drift. tabula-py is 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, pdfplumber is 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 InvoiceRow contract is safer than chasing a marginal accuracy gain with per-invoice tuning. Feed the stable contract into Entry Summary Filing Schemas.

Up: Commercial Invoice PDF Extraction