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?
| Library | Output | Good for | Watch out for |
|---|---|---|---|
| pdfplumber | Lists of rows | Fine control, debugging with visual output | You build the DataFrame yourself |
| Camelot | pandas DataFrames, CSV, Excel | Ruled tables (lattice), batch export | Header is row 0 |
| PyMuPDF | Lists of rows or DataFrames | Speed, no extra dependencies | Header 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:
| Model | Table returned | Cells correct |
|---|---|---|
| mistral/mistral-ocr | Yes | 25 of 25 |
| zai/glm-ocr | Yes | 25 of 25 |
| paddle/paddleocr-vl | Yes | 25 of 25 |
| jina/jina-ocr | Yes | 25 of 25 |
| azure/document-intelligence | Yes | 25 of 25 |
| google/document-ai | No table block | n/a |
| aws/textract | No table block | n/a |
| tesseract/tesseract | No table block | n/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 use1.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.