Compare
pdfplumber vs tabula-py
tabula-py wraps tabula-java, a mature table extractor, and on our three test tables its defaults were exact where pdfplumber's defaults found only one. The trade-offs are elsewhere: it needs Java, each call carries Java's start-up cost, and when it gets a table wrong it does so quietly. All measured below.
Short answer
| pdfplumber 0.11.10 | tabula-py 2.10.0 | |
|---|---|---|
| Ruled, ruled-rows-only and borderless tables, defaults | Exact on 1 of 3 | Exact on 3 of 3 |
| The same, after one adjustment | Exact on 3 of 3 | |
| Merged cell | Marked with None, columns kept |
Later cells shift one column left |
| Time per table | A few hundredths of a second | About 1 s; a fraction of that with jpype1, after a slow first call |
| Needs | Python only | Python and a Java runtime |
| Install | 58.7 MB | 159.5 MB, plus Java (286 MB on our machine) |
| Also gives text, words, positions | Yes | No: tables only |
Use tabula-py when tables are all you need, Java is no obstacle, and you want good results without tuning. Use pdfplumber when you need the rest of the page as well, want control over how cells are found, or deploy where Java is unwelcome (serverless functions, slim containers).
Defaults, checked against the source data
import json
import pdfplumber
import tabula
cases = [("invoice.pdf", "invoice.json", "table"), ("borderless-table.pdf", "borderless-table.json", "table"),
("bank-statement.pdf", "bank-statement.json", None)]
for pdf_name, truth_name, key in cases:
truth = json.load(open(f"samples/truth/{truth_name}"))
expected = truth[key] if key else [truth["header"]] + truth["rows"][:22] # statement: page 1
dfs = tabula.read_pdf(f"samples/{pdf_name}", pages=1, pandas_options={"header": None, "dtype": str})
by_tabula = dfs[0].fillna("").values.tolist() if dfs else []
with pdfplumber.open(f"samples/{pdf_name}") as pdf:
by_plumber = pdf.pages[0].extract_table() or []
print(f"{pdf_name:21} tabula: {len(by_tabula):>2} rows, exact {by_tabula == expected!s:5}"
f" pdfplumber defaults: {len(by_plumber):>2} rows, exact {by_plumber == expected}")invoice.pdf tabula: 8 rows, exact True pdfplumber defaults: 8 rows, exact True
borderless-table.pdf tabula: 7 rows, exact True pdfplumber defaults: 0 rows, exact False
bank-statement.pdf tabula: 23 rows, exact True pdfplumber defaults: 0 rows, exact Falsetabula-py’s default mode guesses where the table is and whether to use ruling lines or text alignment, and on these three pages it guessed right every time, including the bank statement that has no column lines and the borderless table. pdfplumber’s default strategy follows drawn lines only, so it found the ruled invoice and nothing else; with column positions from the header words it is exact on the other two as well (borderless tables, bank statements). Our benchmark has the same result in numbers.
Where it went wrong: merged cells
import pdfplumber
import tabula
dfs = tabula.read_pdf("samples/merged-cells.pdf", pages=1, pandas_options={"header": None, "dtype": str})
tuesday_tabula = dfs[0].fillna("").values.tolist()[3]
with pdfplumber.open("samples/merged-cells.pdf") as pdf:
tuesday_plumber = pdf.pages[0].extract_table()[3]
print("columns: ['Day', 'North hall', 'Lab B', 'Studio']")
print("tabula: ", tuesday_tabula)
print("pdfplumber:", tuesday_plumber)columns: ['Day', 'North hall', 'Lab B', 'Studio']
tabula: ['Tuesday', 'Coordinates and cropping (double session, both rooms)', 'Visual debugging clinic', '']
pdfplumber: ['Tuesday', 'Coordinates and cropping (double session, both rooms)', None, 'Visual debugging clinic']On the merged-cells sample, Tuesday’s double session spans North hall and Lab B. tabula-py returns it in the first room column and then puts Visual debugging clinic, which belongs to Studio, under Lab B: everything after the merge moves one column left, and the last column is left empty. There is no warning, and the result looks plausible.
pdfplumber keeps the grid: the position under the merge is None and Visual debugging clinic stays in Studio, so you can fill or rebuild merged cells knowing which positions they cover. tabula-py also joins lines inside a cell with \r rather than \n, worth knowing when you clean text.
Java: start-up cost and installation
tabula-py runs the Java library tabula-java. Without the optional jpype1 package, every call starts a new Java process; with it, Java starts once and stays in memory:
import time
import tabula
def timed(**kwargs):
t = time.perf_counter()
tabula.read_pdf("samples/invoice.pdf", pages=1, **kwargs)
return f"{time.perf_counter() - t:.2f}s"
# With jpype1 installed, tabula-py keeps one Java VM inside Python
print("Java kept running (jpype):", [timed() for _ in range(3)])
# Otherwise every call starts a new Java process (once forced, it stays that way)
print("new Java process per call:", [timed(force_subprocess=True) for _ in range(3)])Java kept running (jpype): ['1.57s', '0.32s', '0.59s']
new Java process per call: ['1.54s', '1.01s', '0.91s']So install jpype1 (pip install jpype1) for anything that extracts more than a few tables. Note the last line’s comment: in tabula-py 2.10.0, once a call uses force_subprocess=True, the rest of the process stays in subprocess mode. pdfplumber, with no Java involved, reads the same table in a few hundredths of a second (measured).
Java itself has to be installed separately. Without it:
import os
import subprocess
import sys
# Run tabula-py where no java command can be found (subprocess mode, as without jpype1)
env = {k: v for k, v in os.environ.items() if k not in ("JAVA_HOME", "JAVA_TOOL_OPTIONS")}
env["PATH"] = os.path.dirname(sys.executable) # only this Python's scripts, no java
code = "import tabula; tabula.read_pdf('samples/invoice.pdf', pages=1, force_subprocess=True)"
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env=env)
print(result.stderr.strip().splitlines()[-1][:160])tabula.errors.JavaNotFoundError: `java` command is not found from this Python process.Please ensure Java is installed and PATH is set for `java`On Linux that is a package such as openjdk-21-jre-headless (286 MB installed on our test machine); on Windows and macOS, a Java installer, with java on the PATH or JAVA_HOME set. In Docker images and serverless functions, that is the largest part of the deployment.
- pdfplumber59 MB
- tabula-py160 MB plus Java
- tabula-py + Java 21 JRE446 MB
Source: our CI run on ubuntu-latest (Python packages); OpenJDK 21 headless JRE measured on our test machine
Which to use
- Tables only, varied layouts, Java available: tabula-py with
jpype1. Its defaults are good; check tables with merged cells by eye or against totals. - Tables plus text, fields or positions: pdfplumber, one library for the whole page.
- Serverless, slim containers, or no Java allowed: pdfplumber (Lambda recipe).
- You need to know why a table failed: pdfplumber’s visual debugging shows every edge and cell it used.
Camelot is the third table-focused option, with several detection strategies and no Java requirement.