Fix Camelot Finding No Tables in a PDF

The call succeeds, no exception is raised, and the result is empty:

>>> tables = camelot.read_pdf("data/statement.pdf", pages="1", flavor="lattice")
>>> tables.n
0
>>> len(tables)
0

An empty TableList is not an error condition in camelot — it is a report that the detector found nothing matching its criteria. Because the two flavours use completely different criteria, "no tables" almost always means the wrong flavour was asked, the wrong pages were scanned, or the detector's line-length threshold rejected the ruling lines that do exist.

Root Cause

Lattice mode finds tables by detecting vector ruling lines: it rasterises the page through Ghostscript, runs morphological transforms to isolate horizontal and vertical segments, and reconstructs cells from their intersections. If the table's grid is drawn as a background image, or the borders are shorter than the detector's threshold, or there are no borders at all, lattice finds zero intersections and returns nothing.

Stream mode ignores lines entirely and clusters text by whitespace gaps between word positions. It nearly always returns something, so a stream call that returns zero tables usually means there is no extractable text on the page at all — a scan, in other words.

Triaging an empty camelot TableList Starting from zero tables returned, the first test asks whether the page has any extractable text. If it does not, the page is a scan and routes to the OCR pipeline. If it does, the second test asks whether the page has vector ruling lines. With lines present, the fix is to lower line_scale so shorter borders are detected. With no lines present, the fix is to switch to stream flavour and supply column separators. tables.n == 0 no exception raised Any text on the page? NO Page is a scan route to OCR first YES Vector ruling lines present? YES Lower line_scale 40 to 80, keep lattice NO Switch to stream add columns= hints Both fixes assume the right pages were scanned — check the pages argument before tuning anything

Minimal Diagnostic

Answer the tree's three questions in one script. This uses pdfplumber to look at the page the way camelot's detector does, without guessing.

# pip install pdfplumber "camelot-py[cv]"
from pathlib import Path
import pdfplumber

PDF = Path("data/statement.pdf")

def page_profile(pdf_path: Path, page_number: int = 1) -> dict:
    """Report the three facts that decide which camelot flavour can work."""
    if not pdf_path.exists():
        raise FileNotFoundError(f"No such file: {pdf_path}")
    try:
        with pdfplumber.open(str(pdf_path)) as pdf:
            page = pdf.pages[page_number - 1]
            chars = len(page.chars)
            h_lines = [ln for ln in page.lines if abs(ln["y0"] - ln["y1"]) < 1]
            v_lines = [ln for ln in page.lines if abs(ln["x0"] - ln["x1"]) < 1]
            return {
                "page": page_number,
                "characters": chars,
                "horizontal_lines": len(h_lines),
                "vertical_lines": len(v_lines),
                "rects": len(page.rects),
                "rotation": page.rotation,
            }
    except Exception as exc:
        raise RuntimeError(f"Could not profile {pdf_path}: {exc}") from exc

if __name__ == "__main__":
    print(page_profile(PDF, 1))

Read the output against these rules:

{'characters': 0, ...}                       -> scanned page: OCR it, camelot cannot help
{'characters': 1840, 'horizontal_lines': 0,
 'vertical_lines': 0, 'rects': 0}            -> no grid: use flavor='stream'
{'characters': 1840, 'horizontal_lines': 14,
 'vertical_lines': 6}                        -> grid exists: lattice should work, tune line_scale
{'rects': 22, 'horizontal_lines': 0}         -> grid drawn as filled rectangles, not lines

That last case is the one that surprises people. Some generators draw table borders as thin filled rectangles rather than stroked lines. pdfplumber reports them under rects; camelot's line detector, working on the rasterised image, usually still finds them — but only at a low enough line_scale.

Fix: Match the Flavour to the Page, Then Tune line_scale

line_scale divides the page dimension to set the minimum length a segment must have to count as a ruling line. The default of 15 means "a line must be at least a fifteenth of the page long". Raising the number lowers the threshold, so shorter borders are detected — counter-intuitive, and the single most useful knob on this page.

# pip install "camelot-py[cv]" pandas
from pathlib import Path
import camelot

PDF = Path("data/statement.pdf")

def extract_with_fallback(pdf_path: Path, pages: str = "1"):
    """Try lattice at increasing sensitivity, then fall back to stream."""
    if not pdf_path.exists():
        raise FileNotFoundError(f"No such file: {pdf_path}")

    for line_scale in (15, 40, 80):
        try:
            tables = camelot.read_pdf(
                str(pdf_path),
                pages=pages,
                flavor="lattice",
                line_scale=line_scale,   # higher = shorter segments accepted as lines
            )
        except Exception as exc:
            raise RuntimeError(f"camelot failed at line_scale={line_scale}: {exc}") from exc
        if tables.n:
            print(f"lattice found {tables.n} table(s) at line_scale={line_scale}")
            return tables

    tables = camelot.read_pdf(str(pdf_path), pages=pages, flavor="stream")
    print(f"stream found {tables.n} table(s)")
    return tables

if __name__ == "__main__":
    tables = extract_with_fallback(PDF, pages="1")
    if tables.n:
        print(tables[0].df.head())

Two arguments in that call are quietly load-bearing. pages defaults to "1" — not "all" — so a table on page 3 of a statement is invisible unless you ask for it. And flavor defaults to "lattice", which is why borderless reports so often produce an empty list on a first attempt.

Variant Fix 1: The Table Is There but on Other Pages

# pip install "camelot-py[cv]"
import camelot

# Scan everything, then report which pages actually held tables
tables = camelot.read_pdf("data/statement.pdf", pages="all", flavor="stream")
by_page = {}
for table in tables:
    by_page.setdefault(table.page, 0)
    by_page[table.page] += 1
print(by_page)     # {3: 1, 4: 2}

Scanning pages="all" on a 300-page document is slow because each page is rasterised in lattice mode. Narrow it once you know where the tables live: pages="3-4" costs a fraction of the full sweep. When the target pages vary per file, detect them from the text layer first — a keyword search with pdfplumber is far cheaper than a full camelot pass.

Variant Fix 2: Constrain the Search Area

When a page has one real table plus header blocks that confuse the detector, give camelot explicit coordinates. table_areas takes PDF points as "x1,y1,x2,y2" with the origin at the bottom-left of the page.

# pip install "camelot-py[cv]" pdfplumber
from pathlib import Path
import camelot
import pdfplumber

def page_size(pdf_path: Path, page_number: int = 1) -> tuple[float, float]:
    """Page width and height in PDF points, for building table_areas."""
    with pdfplumber.open(str(pdf_path)) as pdf:
        page = pdf.pages[page_number - 1]
        return float(page.width), float(page.height)

if __name__ == "__main__":
    pdf = Path("data/statement.pdf")
    width, height = page_size(pdf)
    # Bottom two-thirds of the page, full width, skipping the letterhead
    area = f"0,{height * 0.66:.0f},{width:.0f},0"
    tables = camelot.read_pdf(str(pdf), pages="1", flavor="stream", table_areas=[area])
    print(tables.n, "table(s) inside", area)

With flavor="stream", pair table_areas with columns — a comma-separated list of x-coordinates where column boundaries fall — when the whitespace clustering splits a column in two. That combination turns an unreliable guess into a deterministic parse, which matters when the same template arrives every month.

What line_scale changes in lattice detection Two renderings of the same table region. On the left, at line_scale 15, only the outer border is long enough to be accepted, so no intersections form inside and zero tables are reported. On the right, at line_scale 80, the short inner rules pass the threshold, intersections form a three by three grid, and one table is reported. line_scale=15 (default) line_scale=80 inner rules below threshold 0 intersections → 0 tables short rules now accepted 4 intersections → 1 table Raising line_scale lowers the minimum segment length — start at 40 and stop as soon as the count is right

Variant Fix 3: The Page Is Rotated

A landscape table stored on a portrait page with /Rotate 90 confuses the line detector, because horizontal and vertical are swapped relative to the coordinate system. The profile script reports rotation; when it is non-zero, normalise the file first.

# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader, PdfWriter

def derotate(src: Path, dest: Path) -> Path:
    """Bake page rotation into the page geometry so detectors see it upright."""
    reader = PdfReader(str(src))
    writer = PdfWriter()
    for page in reader.pages:
        angle = int(page.get("/Rotate", 0) or 0)
        if angle:
            page.rotate(-angle)          # apply and clear the flag
        writer.add_page(page)
    with dest.open("wb") as fh:
        writer.write(fh)
    return dest

Run camelot against the de-rotated copy. The same normalisation helps with misaligned coordinates generally, a problem covered from the text side in fix PDF text extraction alignment issues.

The cost of pages="all" A comparison of scanning cost. Lattice mode rasterises each page through Ghostscript at roughly three hundred milliseconds per page, so a three hundred page document takes about ninety seconds. Stream mode reads the text layer at roughly thirty milliseconds per page, about nine seconds for the same document. Narrowing to the four pages that actually hold tables costs about one second in either mode. Scanning a 300-page document lattice, all ~90 s stream, all ~9 s pages="3-6" ~1 s Locate the table pages with a cheap text search first, then pass the range explicitly

Verification

An extraction that returns tables is not yet a correct extraction. Assert on shape and on camelot's own quality score before letting the frame into a pipeline.

# pip install "camelot-py[cv]" pandas
def assert_usable(tables, min_rows: int = 3, min_accuracy: float = 80.0) -> None:
    """Reject empty, tiny or low-confidence extractions early."""
    assert tables.n > 0, "no tables detected — re-run the diagnostic"
    for i, table in enumerate(tables):
        report = table.parsing_report
        assert report["accuracy"] >= min_accuracy, (
            f"table {i} on page {report['page']}: accuracy {report['accuracy']:.1f}%"
        )
        assert table.df.shape[0] >= min_rows, f"table {i}: only {table.df.shape[0]} row(s)"
        assert table.df.shape[1] >= 2, f"table {i}: collapsed to one column"
    print(f"{tables.n} table(s) passed shape and accuracy checks")

parsing_report["whitespace"] is the companion metric: a high whitespace percentage on a lattice table usually means the grid was detected but the cells were empty, which points at a text layer that does not align with the ruling lines rather than at a detection failure. For the wider comparison of when each library is the right tool, see comparing PDF table extraction libraries.

FAQ

Why does camelot return zero tables when I can clearly see a table? Because "seeing a table" is a human judgement about layout, and camelot's test is mechanical: lattice needs intersecting ruling lines, stream needs whitespace-separated text. A table drawn as a background image satisfies neither. Run the profile script — it tells you which of the two criteria the page can meet.

Should I raise or lower line_scale? Raise it to detect shorter lines. The parameter is a divisor, so a larger value produces a smaller minimum length. Values between 40 and 80 recover most thin-ruled financial tables; beyond 100 you start detecting underlines and strikethroughs as table borders.

Does pages="all" slow things down much? In lattice mode, yes — every page is rasterised through Ghostscript. Expect roughly 150–400 ms per page. Locate the table pages with a cheap text search first, then pass an explicit range.

Stream mode returns one table with all columns merged. Is that a detection failure? No, it is a clustering failure: the gaps between columns are narrower than camelot's threshold. Pass explicit columns x-coordinates, or increase the row tolerance, rather than switching library.

camelot works locally but returns nothing in Docker — same file, same code. Check Ghostscript is installed in the image. Without it, lattice silently degrades on some versions instead of raising, producing exactly this symptom; see fix camelot import error on Linux for the full dependency list.

Part of Extracting Tables from PDFs.