Fix tabula-py Returns Empty DataFrame
tabula runs without an error and returns nothing useful:
>>> import tabula
>>> tabula.read_pdf("in/remittance.pdf")
[]
>>> tabula.read_pdf("in/remittance.pdf", pages="all")
[Empty DataFrame
Columns: [Unnamed: 0]
Index: []]
The table is visible in any PDF viewer — bordered on some pages, borderless on others — and pdfplumber or camelot can see text on the same pages. No warning explains why tabula found nothing.
Root Cause
tabula-py is a wrapper around tabula-java, and several of its defaults quietly narrow what it looks at. read_pdf reads only page 1 unless pages is given, so tables on later pages are never examined. With the default guess=True, tabula first runs its own table detection and extracts only from regions it detected; on borderless tables and busy pages detection often finds nothing, and extraction then returns an empty list. When you pass an area, it is ignored while guess=True is still on. Lattice mode (lattice=True) only builds cells from drawn ruling lines, so a borderless table yields empty frames; stream mode without lines needs whitespace between columns and can collapse a table into a single Unnamed: 0 column with no rows if the area is wrong. And tabula reads text, so scanned pages produce nothing in any mode. Java errors are a separate failure — those raise rather than return empty results.
Minimal Diagnostic
Probe the file systematically: confirm there is text on each page, then run tabula with each mode and guessing on and off, and print the shapes it returns.
# pip install tabula-py pdfplumber
from pathlib import Path
import pdfplumber
import tabula
SOURCE = Path("in/remittance.pdf")
def probe(pdf_path: Path) -> None:
with pdfplumber.open(pdf_path) as pdf:
for i, page in enumerate(pdf.pages, start=1):
chars = len(page.chars)
lines = len(page.lines) + len(page.rects)
print(f"page {i}: {chars} chars, {lines} ruling lines/rects")
combos = [dict(), dict(pages="all"), dict(pages="all", guess=False),
dict(pages="all", lattice=True), dict(pages="all", stream=True, guess=False)]
for kwargs in combos:
try:
frames = tabula.read_pdf(str(pdf_path), multiple_tables=True, **kwargs)
shapes = [f.shape for f in frames]
except Exception as exc:
shapes = f"ERROR {type(exc).__name__}: {exc}"
print(f"{kwargs or 'defaults'}: {shapes}")
if __name__ == "__main__":
probe(SOURCE)
page 1: 212 chars, 0 ruling lines/rects
page 2: 1830 chars, 0 ruling lines/rects
page 3: 1644 chars, 48 ruling lines/rects
defaults: []
{'pages': 'all'}: [(0, 1)]
{'pages': 'all', 'guess': False}: [(3, 2), (41, 6), (37, 6)]
{'pages': 'all', 'lattice': True}: [(18, 6)]
{'pages': 'all', 'stream': True, 'guess': False}: [(3, 2), (41, 6), (37, 6)]
Page 1 is a cover letter; the table starts on page 2 and is borderless there, bordered on page 3. Defaults look only at page 1. Guessing finds nothing on the borderless page. Lattice sees only the bordered page. Stream without guessing finds data on every page.
Fix: Read the Right Pages, in the Right Mode, Without Guessing
Specify pages explicitly, choose stream or lattice per page from whether ruling lines exist, and turn guessing off whenever you supply an area or know the layout. Changed lines carry comments.
# pip install tabula-py pdfplumber "pandas>=2.2"
from pathlib import Path
import pandas as pd
import pdfplumber
import tabula
SOURCE = Path("in/remittance.pdf")
HEADER = ["Invoice", "Date", "Gross", "Discount", "Paid", "Balance"]
def page_modes(pdf_path: Path, min_chars: int = 300) -> dict[int, str]:
modes = {}
with pdfplumber.open(pdf_path) as pdf:
for i, page in enumerate(pdf.pages, start=1):
if len(page.chars) < min_chars:
continue # changed: skip cover/blank pages
modes[i] = "lattice" if len(page.lines) + len(page.rects) > 10 else "stream" # changed: per page
return modes
def read_tables(pdf_path: Path) -> pd.DataFrame:
frames = []
for page, mode in page_modes(pdf_path).items():
try:
found = tabula.read_pdf(
str(pdf_path),
pages=page, # changed: explicit page
guess=False, # changed: no detection step
lattice=(mode == "lattice"),
stream=(mode == "stream"),
multiple_tables=True,
pandas_options={"header": None, "dtype": str}, # changed: keep raw text
)
except Exception as exc:
raise RuntimeError(f"tabula failed on page {page}: {exc}") from exc
for frame in found:
frame = frame.dropna(how="all")
if frame.shape[1] == len(HEADER):
frames.append(frame.assign(page=page))
if not frames:
raise ValueError("no table with the expected column count on any page")
combined = pd.concat(frames, ignore_index=True)
combined.columns = [*HEADER, "page"]
return combined[combined["Invoice"].str.match(r"^INV-\d+", na=False)] # changed: drop header rows
if __name__ == "__main__":
print(read_tables(SOURCE).head())
pandas_options={"header": None} stops tabula from promoting the first row — often a title line or a fragment — to column names, which is where many Unnamed: 0 frames come from. Filtering by the expected column count and an invoice-number pattern discards the cover letter fragment and repeated headers in one pass. Reading everything as strings keeps amounts like 1,240.50 intact for explicit parsing later.
Variant Fix 1: Restrict to an Area — With Guessing Off
Busy pages where stream mode picks up the letterhead or footer benefit from an explicit area. tabula ignores area unless guess=False:
# pip install tabula-py
import tabula
frames = tabula.read_pdf(
"in/remittance.pdf",
pages=2,
area=[160, 30, 760, 565], # top, left, bottom, right in points from the top-left
guess=False, # required, or the area is silently ignored
stream=True,
columns=[95, 170, 270, 350, 440], # optional x boundaries between columns
pandas_options={"header": None, "dtype": str},
)
print([f.shape for f in frames])
area values can also be percentages of the page with relative_area=True, which helps when the same layout is printed on A4 and Letter. Measuring the box from anchor text rather than by eye is covered in extract a PDF table by bounding box coordinates.
Variant Fix 2: Scanned Pages and Encoding Problems
A page with zero characters in the diagnostic is an image. tabula cannot read it in any mode; add a text layer with OCR first, then run the same code:
# pip install ocrmypdf tabula-py
from pathlib import Path
import ocrmypdf
def ocr_then_tabula(src: Path, work: Path):
work.mkdir(parents=True, exist_ok=True)
ocred = work / src.name
ocrmypdf.ocr(src, ocred, skip_text=True, progress_bar=False)
return read_tables(ocred) # the fix function above
When pages have characters but tabula returns frames full of mojibake or empty strings, the problem is text encoding rather than detection: tabula-java needs the correct encoding for its output. Pass encoding="utf-8" (the default) or, on Windows systems with a legacy default charset, java_options=["-Dfile.encoding=UTF8"] so accented text survives the handoff from Java. If the characters themselves decode as (cid:..) in every library, see fix CID garbled characters in PDF text.
Keeping tabula Fast in Batch Jobs
Every read_pdf call starts a Java virtual machine unless tabula-py is told otherwise, which adds about a second per call — the per-page loop above multiplies it. Recent tabula-py versions can reuse one JVM in-process through jpype, and batch conversion of many files in one call avoids repeated start-up:
# pip install "tabula-py[jpype]"
import time
import tabula
start = time.perf_counter()
for page in range(2, 12):
tabula.read_pdf("in/remittance.pdf", pages=page, guess=False, stream=True,
force_subprocess=False) # use the in-process JVM when jpype is installed
print(f"10 calls: {time.perf_counter() - start:.1f}s")
# whole folder in one Java invocation, writing CSV next to each PDF
tabula.convert_into_by_batch("in/remittances", output_format="csv", pages="all",
guess=False, stream=True)
With jpype installed, the JVM starts once and later calls take milliseconds of overhead. convert_into_by_batch is convenient for exports but gives less control over per-page mode choice; use it only for folders of uniformly formatted files. When Java is absent altogether, calls raise instead of returning empty, as described in fix tabula java not found error.
Verification
Assert non-empty output with the expected columns, compare the row count with a control total on the document, and check that amounts parse.
# pip install "pandas>=2.2" pdfplumber
import re
from pathlib import Path
import pandas as pd
import pdfplumber
def verify_remittance(pdf_path: Path, table: pd.DataFrame) -> None:
assert not table.empty, "tabula returned no rows"
amounts = pd.to_numeric(table["Paid"].str.replace(",", ""), errors="coerce")
assert amounts.notna().all(), f"unparseable Paid values: {table.loc[amounts.isna(), 'Paid'].tolist()[:3]}"
with pdfplumber.open(pdf_path) as pdf:
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
total = re.search(r"Total paid\s*[£€$]?\s*([\d,]+\.\d{2})", text)
if total:
expected = float(total.group(1).replace(",", ""))
assert abs(amounts.sum() - expected) < 0.01, f"rows sum to {amounts.sum():.2f}, document says {expected:.2f}"
print(f"{len(table)} rows across pages {sorted(table['page'].unique())}; total {amounts.sum():,.2f}")
A remittance's printed total is the best available check: an empty page, a dropped row or a shifted column all change the sum.
FAQ
Why does tabula's GUI find the table when the Python call does not?
In the GUI you draw an area, which is equivalent to area=... with guess=False. Use the GUI's exported template JSON with tabula.read_pdf_with_template to reuse those exact areas.
Why do I get one column named Unnamed: 0 and no rows?
tabula found a single text block and pandas promoted its first line to the header, leaving an empty body. It usually means the area covers only a title or the page is the wrong one. Pass pandas_options={"header": None} to see the raw rows, then fix the page or area.
Can tabula read password-protected PDFs?
Pass password="...". Without it, encrypted files raise an error or return nothing depending on the encryption type, so decrypt first when in doubt.
Should I always set guess=False?
When you know the pages and layout, yes. Guessing helps for exploratory runs on unfamiliar documents, not in production pipelines.
Why does multiple_tables=False return one huge frame?
It concatenates everything tabula finds on the selected pages into one frame. Keep multiple_tables=True and filter, so unrelated fragments do not pollute the table.
Is tabula still worth using over camelot or pdfplumber? It is good at stream-mode tables and handles large batches efficiently. The trade-offs are in pdfplumber vs camelot vs tabula.
Related
- Comparing PDF Table Extraction Libraries — when tabula is the right tool
- Fix tabula-py Java Not Found Error — the error that raises instead of returning empty
- Fix Camelot No Tables Found — the equivalent problem in camelot
- Handle Multi-Page PDF Tables in pandas — stitching per-page frames into one table