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.
- $0.006 a page, plus $0.00005 a run at the default 1 GB of memory. Failed and blank pages are free.
- The Apify free plan lets you try 20 pages a run and 50 a month.
- Pages are read inside the run. They are not sent to any AI service.
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
- Tables with no grid lines, merged cells, or a cell with two lines of text. Every test table had lines and one line per cell.
- Real scans. All 73 pages were made on a computer with a scan look, so real scans can do worse.
- OpenCV line removal, and table libraries built on Tesseract.
- Windows and Linux, other Tesseract versions, and languages other than English. The model was the English one that Homebrew installs.
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.