OCRRouterGet API key

How to extract tables from PDF (with Python), including scanned PDFs

Extract tables from digital PDFs with pdfplumber, Camelot or PyMuPDF, and from scanned PDFs with OCR. Tested code, real output and the traps we hit.

Updated

TL;DR

  • First check if the PDF has a text layer: page.get_text() returns nothing on a scan.
  • On a digital PDF, pdfplumber, Camelot and PyMuPDF all returned our 25-cell table exactly.
  • On a scanned copy, pdfplumber returned an empty list, with no error.
  • For scans, 5 of 8 OCR models we tried returned the full table with all 25 cells correct.

To extract tables from a PDF, first find out what kind of PDF you have. If you can select the text in a viewer, the PDF has a text layer and a Python library such as pdfplumber, Camelot or PyMuPDF reads the table directly, for free, in milliseconds. If the page is a scan or a photo, those libraries return nothing and you need OCR with table recognition. This guide shows both, with code we ran on November 15, 2026 and the exact output.

Step 1: check whether your PDF has a text layer

Open the PDF and try to select a word in the table. If you can, it is a digital PDF. If the whole page selects as one image, it is a scan.

In Python:

import pymupdf  # pip install pymupdf

page = pymupdf.open("sales.pdf")[0]
print(len(page.get_text().strip()))  # 0 means no text layer: you need OCR

This decides everything below. Running a table library on a scan gives an empty result without an error, which is easy to miss.

Our test files

We made one small table with ReportLab so we know the exact right answer: a "Quarterly sales (units)" heading above a 5 x 5 grid with ruled lines (Region, Q1 to Q4, and four regions). Then we made a second copy that is only a picture of the page, rendered at 150 dpi, to stand in for a scanned document.

  • sales.pdf: digital, with a text layer.
  • sales-scan.pdf: the same page as an image, no text layer.

Both have 25 cells. A correct extraction returns all 25 with the right values in the right place.

Extract a table from a digital PDF in Python

pdfplumber

pdfplumber reads the characters and lines in a PDF and groups them into tables.

import pdfplumber

with pdfplumber.open("sales.pdf") as pdf:
    for page in pdf.pages:
        for table in page.extract_tables():
            for row in table:
                print(row)

Output:

['Region', 'Q1', 'Q2', 'Q3', 'Q4']
['North', '1,200', '1,350', '1,410', '1,580']
['South', '980', '1,020', '1,105', '1,190']
['East', '1,430', '1,390', '1,520', '1,610']
['West', '760', '845', '900', '1,020']

All 25 cells correct, and it ignored the heading above the table. pdfplumber also lets you tune detection with table_settings, for example {"vertical_strategy": "text"} for tables with no ruled lines.

Camelot

Camelot returns each table as a pandas DataFrame. Its lattice mode follows the ruling lines; stream mode uses gaps between words for tables without lines.

import camelot

tables = camelot.read_pdf("sales.pdf", pages="1", flavor="lattice")
print(tables.n, "table(s)")
df = tables[0].df
print(df)
df.to_csv("sales.csv", index=False, header=False)

Output:

1 table(s)
        0      1      2      3      4
0  Region     Q1     Q2     Q3     Q4
1   North  1,200  1,350  1,410  1,580
2   South    980  1,020  1,105  1,190
3    East  1,430  1,390  1,520  1,610
4    West    760    845    900  1,020

All 25 cells correct. Camelot puts the header in row 0, so promote it yourself with df.columns = df.iloc[0] if you need named columns. We installed camelot-py 1.0.9 with pip and lattice mode worked without extra system packages.

PyMuPDF

PyMuPDF has a built-in find_tables() method and no external dependencies.

import pymupdf  # pip install pymupdf

doc = pymupdf.open("sales.pdf")
for page in doc:
    for table in page.find_tables().tables:
        for row in table.extract():
            print(row)

table.extract() gave the same 25 correct cells. One thing to watch: when we first used table.to_pandas() instead, PyMuPDF built the column names from the text above the grid. The first column was named nits) (from "Quarterly sales (units)") and the others Col1 to Col4, while the real header became the first data row. Check headers before you trust a DataFrame.

Which library for digital PDFs?

LibraryOutputGood forWatch out for
pdfplumberLists of rowsFine control, debugging with visual outputYou build the DataFrame yourself
Camelotpandas DataFrames, CSV, ExcelRuled tables (lattice), batch exportHeader is row 0
PyMuPDFLists of rows or DataFramesSpeed, no extra dependenciesHeader guessing in to_pandas()

All three got our simple table right. Differences show up on harder tables: merged cells, tables that span pages, and tables with no lines. Try two libraries on a few of your real files before you commit.

Extract a table from a scanned PDF

We ran pdfplumber on sales-scan.pdf:

import pdfplumber

with pdfplumber.open("sales-scan.pdf") as pdf:
    print(pdf.pages[0].extract_tables())

It printed []. No error, no table. There is no text in the file for it to read, so you need OCR that understands tables.

Option A: OCR the image yourself

Classic OCR engines like Tesseract return text lines, not cells. On our invoice test, Tesseract returned the table as A4 paper, 5 reams 4 $24.00 $96.00, with nothing to tell you where one column ends and the next begins. You can rebuild columns from word coordinates (pytesseract.image_to_data gives every word's box), but that is fragile code to maintain. PaddleOCR has a table recognition pipeline if you want to stay local.

Option B: an OCR API that returns tables

We sent sales-scan.pdf to eight models through the OCRRouter API, asked for JSON, and printed every block of type table:

import base64, os, sys, requests

API = os.environ.get("OCRROUTER_API", "https://api.ocrrouter.ai")
model = sys.argv[1] if len(sys.argv) > 1 else "mistral/mistral-ocr"

with open("sales-scan.pdf", "rb") as f:
    b64 = base64.b64encode(f.read()).decode()

r = requests.post(
    f"{API}/v1/ocr",
    headers={"Authorization": f"Bearer {os.environ['OCRROUTER_API_KEY']}"},
    json={"model": model, "file_base64": b64, "format": "json"},
    timeout=180,
)
r.raise_for_status()
for page in r.json()["pages"]:
    for block in page["blocks"]:
        if block["type"] == "table":
            for row in block["rows"]:
                print(row)

Run it as python extract.py zai/glm-ocr to try another model. Results on November 15, 2026:

ModelTable returnedCells correct
mistral/mistral-ocrYes25 of 25
zai/glm-ocrYes25 of 25
paddle/paddleocr-vlYes25 of 25
jina/jina-ocrYes25 of 25
azure/document-intelligenceYes25 of 25
google/document-aiNo table blockn/a
aws/textractNo table blockn/a
tesseract/tesseractNo table blockn/a

The last three still returned the text; it just was not grouped into a table in our output. Note that AWS Textract and Google Document AI both have dedicated table features in their own APIs (Textract tables); our result reflects the general OCR mode we call, not everything those services can do.

This is one clean table. It tells you which models return structure at all, not which is most accurate on hard tables. For that, see the table scores in OmniDocBench and olmOCR-Bench.

Export the table to CSV or Excel

Once you have rows, pandas does the rest:

import pandas as pd

rows = [["Region", "Q1"], ["North", "1,200"]]  # rows from any method above
df = pd.DataFrame(rows[1:], columns=rows[0])
df.to_csv("table.csv", index=False)

For Excel, df.to_excel("table.xlsx", index=False) needs openpyxl installed. If you do not want to write code, our free PDF to Excel and PDF to JSON tools run the same models in the browser.

Problems you will hit on real files

  • Numbers as text. Every method returns "1,200" as a string. Strip separators and convert before you sum anything, and watch for locales that use 1.200,50.
  • Tables across pages. Each page gives a separate table. Join them on matching headers, and drop repeated header rows.
  • Merged cells. Libraries usually put the value in the first cell and leave the rest empty or None. Decide whether to fill down.
  • No ruling lines. Switch Camelot to flavor="stream" or pdfplumber to text-based strategies.
  • Scans at low resolution. Render or scan at 300 dpi or more. Our accuracy guide shows how fast OCR falls apart on small text.

Summary

Digital PDF: use pdfplumber, Camelot or PyMuPDF; all three got our table exactly right. Scanned PDF: those return nothing, so use OCR with table output; five of the eight models we tried returned the full table with every cell correct.

FAQ

Read next