Tools,

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

Exact tables, of 84 PDFs
The gap recipe
84
Camelot stream
42
Our Actor's code
32
pdfplumber default
12
PyMuPDF default
11
pdfplumber text strategy
0
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.

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.