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.
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.
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.
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.
Related
- Extracting Tables from PDFs — the full lattice and stream workflow
- Fix Camelot Import Error on Linux — Ghostscript and OpenCV dependency failures
- How to Extract Tables from Scanned PDFs — the OCR route when the page has no text
- Comparing PDF Table Extraction Libraries — when camelot is the wrong tool entirely
- Fix PDF Text Extraction Alignment Issues — coordinate problems that survive detection
Part of Extracting Tables from PDFs.