Tools,

Tesseract table extraction: rebuild the rows yourself, tested on 4,575 cells

Tesseract does not return tables. It returns words and their boxes from image_to_data, and you rebuild the rows and cells. The code below did that on 73 test scans with grid lines and got 3,498 of 4,575 cells exactly right (76%) in the default mode, psm 3. psm 6, which treats the page as one block of text, got 87 (2%). Nothing flags the wrong cells.

Try it on your PDF, $0.006 a page

The code

Tesseract 5.5.3, pytesseract 0.3.13, Python 3.13, on a Mac. No pandas, no OpenCV. Rows come from the middle of each word box, and a wide gap between two words starts a new cell. Grid lines come back as |, [ or ], so each word loses those first.

from itertools import pairwise

import pytesseract
from pytesseract import Output


def cells_by_gap(row, gap=0.75):
    # A gap wider than 3/4 of the row's typical word height starts a new cell.
    h = sorted(w[3] for w in row)[len(row) // 2] * gap
    cells = [[row[0]]]
    for prev, w in pairwise(row):
        if w[1] - (prev[1] + prev[2]) > h:
            cells.append([w])
        else:
            cells[-1].append(w)
    return [" ".join(w[4] for w in c) for c in cells]


def table(image, psm=3):
    d = pytesseract.image_to_data(image, config=f"--psm {psm}", output_type=Output.DICT)
    words = []
    for i, text in enumerate(d["text"]):
        text = text.strip().strip("|[]")  # grid lines come back as | [ ]
        if d["level"][i] == 5 and text:
            mid = d["top"][i] + d["height"][i] / 2
            words.append((mid, d["left"][i], d["width"][i], d["height"][i], text))
    # Rows: a word joins the row above when its middle is within half its height.
    rows = []
    for w in sorted(words):
        if rows and w[0] - rows[-1][-1][0] < w[3] / 2:
            rows[-1].append(w)
        else:
            rows.append([w])
    return [cells_by_gap(sorted(r, key=lambda w: w[1])) for r in rows]

Pass the file path, not a PIL image, so Tesseract reads the file's own dpi. Then keep the rows with as many cells as your table has columns:

import csv
import sys

rows = table("invoice-scan.jpg")
csv.writer(sys.stdout).writerows(r for r in rows if len(r) == 5)

On one of the test invoices (200 dpi, 12 item rows), this printed:

SKU,Description,Qty,Unit price,Amount
1001,Joint tape,8,4.72,37.76
1002,Tile spacers,1,20.75,20.75
1003,Copper elbow 1/2in,1,54.98,54.98
1004,Hinge pair,1,24.26,24.26
1005,Grout bag,To,887,62.09
1006,Door closer,1,8.44,8.44
1007,Labor (hours),6,7.88,47.28
1008,Filter pack,12,192.16,"2,305.92"
1009,Conduit 10ft,8,80.17,641.36
1010,Ladder rental,11,24.71,271.81
1011,Sump pump,12.,7.62,91.44
1012,Breaker 20A,6,LTA,"1,066.74"

The shape is right, and 4 cells are wrong. The printed page says 7, 8.87, 12 and 177.79. Tesseract read To, 887, 12. and LTA, and nothing in the output says so.

Does Tesseract have table recognition?

It has table detection, not table output. In tesseract --print-parameters, textord_tabfind_find_tables is 1, "run table detection", and textord_tablefind_recognize_tables is 0, "Enables the table recognizer for table layout and filtering." Even with the recognizer on, the 5.5.3 source keeps only the table's outline: found_table->set_bounding_box(table_structure->bounding_box()); and then delete table_structure;. The txt, hOCR, TSV, ALTO and PDF outputs have no rows or columns.

Tesseract's own guide says so too: "It is known tesseract has a problem to recognize text/data from tables (see issues tracker) without custom segmentation/layout analysis."

Which psm to use for a table

The test: our scanned test invoice plus 72 more made-up invoices and stock lists, all made on a computer with a scan look (tilt, blur, noise, JPEG, 150 to 300 dpi). Each has a 5-column table with grid lines, and I know the text of every cell. 915 rows, 4,575 cells, 2,454 of them numbers. A cell counts as right only when it is in the right place and matches the printed text exactly.

Mode Cells right Rows right in every cell Numbers right
--psm 3 (default) 3,498 of 4,575 (76%) 509 of 915 (56%) 1,963 of 2,454 (80%)
--psm 4 3,484 (76%) 504 (55%) 1,955 (80%)
--psm 11 3,056 (67%) 413 (45%) 1,301 (53%)
--psm 6 87 (2%) 1 (0%) 12 (0%)

psm 6 means "Assume a single uniform block of text." A table with grid lines is not that, and the text came out as noise. The same invoice as above, its first three table lines, with image_to_string(path, config="--psm 6"):

or fioeoge [8 fare [76 _|
orators) fo _[van [728 |
econo [fs [eas |

So try psm 3 first, and psm 6 only on a table with no lines. I did not test tables with no lines here.

Why the rows come from the boxes

image_to_data also numbers each line (block_num, par_num, line_num). In psm 3 those lines worked almost as well: 3,468 cells, against 3,498 from the boxes. In psm 11 ("Sparse text. Find as much text as possible in no particular order.") they broke every row apart: 17 cells right. Built from the boxes, the same psm 11 words gave 3,056. The box rule works in every mode, so the code uses it.

The gap size matters

A gap that is too small splits "Tile spacers" into two cells. Too large, and narrow columns run together, as in "Qty Unit price Amount". In psm 3:

New cell when the gap is wider than Cells right
0.4 of the word height 2,883
0.5 3,332
0.6 3,456
0.75 3,498
1 3,210

I picked 0.75 on these same pages, so 76% is a best case for this rule. Your font and dpi can want another number.

Removing the grid lines made it worse here

One way to help is to paint the grid lines white before OCR. I tried a plain filter that whitens every run of 60 or more dark pixels. On these scans the lines are soft and noisy, so pieces of them stayed, and psm 3 fell from 3,498 cells to 453. psm 6 rose from 87 to 706, still far below psm 3 with the lines left in. If you remove lines, look at the cleaned image before you trust it. Tesseract's guide points to two outside methods with OpenCV, which I did not test.

What still goes wrong

In psm 3, 782 of the 915 rows came out with 5 cells. Of the other 133, 36 are Subtotal, Tax and Total rows with empty cells. The gap rule cannot see an empty cell, so each of those came out short. To place them, you have to put each cell under its header, which this code does not do. The other 97 had a cell split in two or two cells run together. And 273 of the 782 rows with 5 cells still had a wrong cell. Some from the test, word for word:

Printed Cell from Tesseract
44.83 4483
17.52 1752
1.77 177
2 Z
78.64 ( 78.64

A lost decimal point is the worst kind: 4483 looks like a real amount. On an invoice, the row math catches it. Quantity times unit price must be the amount. In my confidence test on the same 73 pages, a format check plus the row math caught all 138 wrong invoice rows, with no false alarms. The code is on the Tesseract confidence score page. The confidence score alone missed some: 12 wrong numbers scored 90 or more.

To raise the reading itself, see how to improve Tesseract accuracy. For number cells, see Tesseract digits only.

If you do not want to build it

PDF OCR to Markdown is our Apify Actor. It finds the table by its lines, reads each cell with Tesseract, checks the row math and the decimal places, and gives a Markdown table with warnings for doubtful rows and cells. On the same 73 pages, the code of its build 0.1.26 got 4,570 of the 4,575 cells and 2,451 of the 2,454 numbers, and it warned about the rows of all 3 numbers it missed. The current build, 0.1.28, gave the same output on the 72 made-up scans.

For tables in PDFs that already have text, see PDF table to Markdown. For PDFs from Python, see pytesseract PDF to text.

What I did not test

Questions people ask

Can Tesseract extract tables?

Not as tables. Tesseract finds where a table is, but no output format gives rows and columns. You get words with their boxes from image_to_data, and you rebuild the rows and cells yourself.

Which psm is best for a table in Tesseract?

On my 73 test scans with grid lines, the default psm 3 was best: 3,498 of 4,575 cells right. psm 4 got 3,484, psm 11 got 3,056, and psm 6 got 87.

Why does Tesseract read table lines as | characters?

A thin vertical grid line looks like a pipe or a bracket to the OCR. In psm 3, 1,661 words on 73 pages had a | [ or ] in them. Strip those characters from each word before you build cells.

How do I get a Tesseract table into pandas or a CSV file?

Rebuild the rows first, then write them with csv.writer or pass them to pandas.DataFrame. Output.DATAFRAME in pytesseract gives the word rows, not a table, and it needs pandas installed. Without it, pytesseract raises "Missing pandas package".

How accurate is Tesseract on tables?

In my test, 76% of cells and 80% of numbers came out exactly right, and only 56% of rows were right in every cell. Nothing flags the wrong cells, so check the numbers after OCR.

Also in this set: Tesseract confidence score, pytesseract returns an empty string, scanned PDF to text, image to Markdown, Tesseract orientation detection.

How this was made: Tack, our AI personality, ran Tesseract 5.5.3 (Homebrew) and pytesseract 0.3.13 with Pillow 13.0.0 on a Mac on October 31, 2026, and copied every result above from that run. Every code block on this page ran as printed. The scripts and their output are in research/bench/tesseract-table-2026-10-31/. The test pages are ones we made on a computer, with a scan look added. Sources, read October 31, 2026: Improving the quality of the output (Tesseract docs, Tables recognition), tablefind.cpp and colfind.cpp (Tesseract 5.5.3 source), tesseract --help-psm and --print-parameters, and pytesseract.py at 0.3.13. We make the Apify Actor. Tesseract and pytesseract are free tools we do not make.