pdfplumber tables without lines: what works
0 of 72
tables without full grids, read right by default
Try it on your PDF, $0.006 a page
Both built-in strategies miss
import pdfplumber
with pdfplumber.open("statement.pdf") as pdf:
page = pdf.pages[0]
print(page.extract_tables())
text = {"vertical_strategy": "text", "horizontal_strategy": "text"}
print(page.extract_tables(text)[0][2])
[]
['10/01', 'Opening balanc', 'e', '', '', '4,218.40']
The PDF is a bank statement with no lines.
| Table shape | Default settings, exact |
|---|---|
| Full grid | 12 of 12 |
| Rules under rows, 3 rules, or stripes | 0 of 36 |
| No lines | 0 of 36 |
The text strategy cuts words in half
It draws column lines where the left, right or middle of words line up. On the statement, one line ran through "balance", so the "e" got a column of its own. It got 0 of 84 tables exact, even the full grids.
Other settings I tried
| Settings | Exact, of 84 |
|---|---|
lines_strict, both directions |
8 |
Vertical lines, horizontal text
|
6 |
Vertical text, horizontal lines
|
0 |
PyMuPDF find_tables(strategy="text") |
0 |
Step 1: a column line in each gap
Between two columns there is a strip that no word crosses, in any row.
columns() puts a line in the middle of each strip at
least gap points wide.
import pdfplumber
def columns(words, gap=3):
"""x for each column line: both edges, and every gap no word crosses."""
spans = sorted((w["x0"], w["x1"]) for w in words)
edges, end = [spans[0][0]], spans[0][1]
for x0, x1 in spans[1:]:
if x0 - end >= gap:
edges.append((end + x0) / 2)
end = max(end, x1)
return edges + [end]
Step 2: crop, then check the count
Crop from the header to the line above stop, so text
around the table cannot fill the gaps. If you pass ncols,
a wrong column count raises an error.
def table_by_gaps(page, start, ncols=None, stop=None, gap=3):
top = page.search(start, regex=False)[0]["top"]
bottom = page.search(stop, regex=False)[0]["top"] if stop else page.height
area = page.crop((0, top - 1, page.width, bottom - 1))
lines = columns(area.extract_words(), gap)
if ncols and len(lines) - 1 != ncols:
n = len(lines) - 1
raise ValueError(f"{n} columns, not {ncols}: change gap or stop")
table = area.find_table({
"vertical_strategy": "explicit",
"explicit_vertical_lines": lines,
"horizontal_strategy": "text",
})
return join_wrapped(table)
Step 3: join wrapped lines to their row
A cell on 2 lines reads as 2 rows. A line with no first cell joins the nearest row.
def join_wrapped(table):
rows = table.extract()
mid = [(r.bbox[1] + r.bbox[3]) / 2 for r in table.rows]
keys = [i for i, r in enumerate(rows) if r[0]]
groups = {}
for i, r in enumerate(rows):
if any(r):
k = i if r[0] else min(keys, key=lambda k: abs(mid[k] - mid[i]))
groups.setdefault(k, []).append(r)
return [[" ".join(filter(None, c)) for c in zip(*g)]
for g in groups.values()]
with pdfplumber.open("statement.pdf") as pdf:
rows = table_by_gaps(pdf.pages[0], "Date", ncols=5)
for row in rows[:3]:
print(row)
['Date', 'Description', 'Withdrawals', 'Deposits', 'Balance']
['10/01', 'Opening balance', '', '', '4,218.40']
['10/02', 'Card purchase, Harbor Hardware', '86.14', '', '4,132.26']
84 of 84, tuned on these PDFs
| Run of the recipe | Exact |
|---|---|
| First 42 PDFs, after 3 fixes | 42 of 42 |
| 42 new PDFs, first run, gap 4 | 36 of 42 |
| Count check added, gap 3 | 42 of 42 |
I added the check and picked gap 3 after the first run. So both are tested on these PDFs, not proven on yours.
Pick a gap between these two
2.25 pt widest space inside a column
3.79 pt narrowest gap between columns
| Setting | Of 84 PDFs |
|---|---|
| gap=3 | 84 exact, 0 stopped by the check |
| gap=4 | 78 exact, 6 stopped by the check |
| gap=5 | 72 exact, 12 stopped by the check |
| gap=6 | 64 exact, 20 stopped by the check |
Without the check, gap 4 returned 6 wrong tables and no error.
Scanned PDF? pdfplumber reads nothing
pdfplumber read 0 characters from my 4 scanned grid tables. PDF OCR to Markdown, our Apify Actor, reads scans with Tesseract, the free OCR engine.
- Its code got 254 of 256 cells right.
- It misread "Corner" as "Comer", and "E-101" as "E-10]".
- $0.006 a page, plus $0.00005 a run at the default 1 GB. Pages that fail are free.
- The Apify free plan lets you try 20 pages a run and 50 a month.
- On tables without a full grid, its code got 20 of 72 exact. Use the recipe for those.
Questions people ask
Why does pdfplumber not find my table?
The default settings build cells from drawn lines and the sides of boxes. Rules under each row, or colored stripes, give it no lines between the columns. It returned [] or a table with almost no right cells.
What pdfplumber table settings work for tables without lines?
On these PDFs: vertical_strategy explicit, with explicit_vertical_lines from the gaps between words, and horizontal_strategy text. The text strategy for both directions got no table exact. Its column lines often cut through words.
pdfplumber, Camelot or PyMuPDF for tables?
With a full grid, pdfplumber's default got every table exact. With no full grid, Camelot's stream mode did best of the built-in modes I tried, and still got about half wrong. The gap recipe got all 72. PyMuPDF's find_tables() matched pdfplumber's default closely.
Does pdfplumber work on scanned PDFs?
Not unless the scanner added a text layer. A plain scan is a picture of a page. pdfplumber read 0 characters from each of my scanned tables. You need OCR first, such as Tesseract or our Actor.
How do I turn the rows into a pandas DataFrame?
Use the first row as the header: pd.DataFrame(rows[1:], columns=rows[0]). On the test statement that gave 12 rows and 5 columns.
Why are two of my columns merged into one?
The gap is wider than the space between those columns. Lower it, but keep it wider than a space between words, or one column splits in two. With ncols set, a wrong column count raises an error.
Method, limits and sources
How I tested it
84 one-page PDFs with known cells: 4 tables we made up (a bank statement, an invoice, a payroll and a parts list), each in 7 shapes, each saved by Chrome, LibreOffice and reportlab.
The first 42 use 10 and 11 point sans-serif text. I fixed the recipe on them. The other 42 came after, with other data and 9 point serif text. The narrowest gaps came from LibreOffice at 9 point text.
A cell is right when its text matches, with spaces evened out. Exact means every cell right and no extra column. A page title above the table is allowed.
The 4 scans are the grid tables at 300 dpi, blurred, with noise and JPEG grain. The Actor's code ran on this Mac with the same Tesseract models as the Actor, but with Tesseract 5.5.3 from Homebrew, not the Actor's Debian build.
Scripts, true cells and output are in research/bench/pdfplumber-tables-2026-11-02/.
What I did not test
- Real bank statements or invoices. These are made-up tables.
- Headers that appear twice. start and stop use the first match on the page.
- Tables that run onto a second page.
- Merged cells.
- A first column with empty cells. By design, the recipe joins each such line to the nearest row.
- Pages with two tables side by side.
Versions and sources
- pdfplumber 0.11.10, PyMuPDF 1.28.2, Camelot 2.0.0, Python 3.12 on a Mac, November 2, 2026.
- pdfplumber README: the table settings and strategies
- PyMuPDF's table code says parts of it are ported from pdfplumber.
- We make the Apify Actor. pdfplumber, PyMuPDF, Camelot and Tesseract are free tools we do not make.