PDF extraction
How to Extract Data from PDF Python: 2026 Guide
Learn how to extract data from pdf python to capture text and tables. Use libraries like pdfplumber and Camelot for high-accuracy data extraction in 2026.
You're probably here because the first version of your script looked easy:
from PyPDF2 import PdfReader
reader = PdfReader("file.pdf")
text = "".join(page.extract_text() for page in reader.pages)
print(text)
Then reality showed up. One file worked. The next had broken line wraps, missing tables, empty output, or a scan that returned nothing at all.
That's the shape of extract data from pdf python work in production. The problem usually isn't Python. It's that “PDF extraction” means three different jobs: pulling text from digital PDFs, recovering structure from tables and forms, and doing OCR on image-only scans. Each one needs a different approach.
Table of Contents
- Why PDF Data Extraction Is Harder Than It Looks
- A PDF is not a document model
- The file type changes the extraction strategy
- The Right Tool for the Job A Python PDF Library Comparison
- Quick comparison
- The shared workflow most libraries follow
- Extracting Plain Text from Digital PDFs
- A practical pdfplumber baseline
- Cleaning the output before it bites you
- Pulling Tables into Pandas DataFrames
- Camelot works when the PDF actually contains a table shape
- A fallback for harder tables
- Handling Scanned PDFs with OCR
- Detect scans early
- A two-step OCR pipeline in Python
- Common failure points before you hit production
- When to Stop Building and Use an API
- The maintenance cost shows up before the parsing cost
- What the handoff looks like
Why PDF Data Extraction Is Harder Than It Looks
The most common mistake is assuming a PDF stores content like HTML, JSON, or a database row. It usually doesn't. A PDF is much closer to a set of rendering instructions: draw this text here, place this line there, paint this image at these coordinates.

That's why extraction fails in ways that feel irrational. The text you see as a paragraph might be stored as scattered positioned fragments. The table you see visually might just be text blocks aligned near some lines. The form you think has fields might be flattened into static content.
A PDF is not a document model
If you've ever asked “why did it read the right column before the left one?”, that's the answer. The extractor is reconstructing reading order from positions, not reading a semantic document tree.
A few consequences show up fast:
- Text order can drift: Multi-column pages and sidebars often scramble output.
- Whitespace lies: Paragraphs may come back with odd breaks or duplicated newlines.
- Tables aren't tables: Many PDFs contain no explicit table object at all.
- Scans have no text layer: A page can look readable to you and still be an image to Python.
PDFs preserve appearance first. Extraction libraries have to infer structure after the fact.
The file type changes the extraction strategy
Before picking a library, classify the PDF:
- Digitally generated PDF Generated by software, usually with a selectable text layer. Text extractors like PyPDF2, pdfminer-style tools, PyMuPDF, and pdfplumber tend to work best on these files.
- Scanned PDF
The page is an image. Text extraction returns empty strings or garbage. You need OCR.
- Structured form or repeatable layout
Think invoices, statements, tax forms, or filings. Coordinates, selectors, or specialized table logic often matter more than raw text extraction.
Python's ecosystem has grown around exactly these differences. Over the last 10-15 years, developers have gone from basic text scraping to a broader set of tools, including PDFMiner, PyPDF2, PDFQuery, and PyMuPDF, plus specialized options like Camelot for tables and PDFQuery for coordinate-based extraction, as noted in this overview of the Python PDF extraction ecosystem. That evolution is why there isn't one “best” library anymore.
For teams working across invoices, statements, or filings, it helps to think in document categories first, not library names. A useful way to frame that is by document family, like the examples in common PDF document types.
The Right Tool for the Job A Python PDF Library Comparison
If you choose a library before you define the job, you usually end up rewriting the pipeline later. The cleaner approach is to start with the extraction target: plain text, layout-aware text, tables, or scanned pages.

Quick comparison
| Library | Primary Use Case | Handles Scanned PDFs? | Key Advantage | Key Limitation |
|---|---|---|---|---|
| PyPDF2 | Basic text extraction from digital PDFs | No | Simple API and quick to start | Weak on scans and layout fidelity |
| pdfminer.six | Lower-level text and layout extraction | No | More control over parsing details | More setup and more verbose code |
| pdfplumber | Layout-aware text extraction and inspection | No | Easier debugging for positions and page structure | Still depends on an existing text layer |
| Camelot | Table extraction into DataFrames | No | Good fit for explicit table layouts | Not useful for scanned pages without OCR |
| pytesseract | OCR for scanned PDFs after image conversion | Yes | Reads image-only pages | Slower, noisier, and sensitive to scan quality |
This isn't about winners and losers. It's about matching the library to the failure mode you're dealing with.
The shared workflow most libraries follow
Most Python PDF tools now converge on the same basic workflow: file loading, content conversion to an intermediate representation in some cases, data access via coordinates or selectors, and export to structured output, as described in this guide to PDF extraction workflows in Python.
That consistency matters because it lets you swap approaches without rebuilding your app shape. The library changes, but the pipeline usually still looks like this:
- Load the file into a document object.
- Iterate page by page instead of assuming whole-document parsing.
- Use location-aware access when layout matters.
- Export into a format your app already uses, usually text, JSON, CSV, or a DataFrame.
If you want a second opinion focused specifically on text extraction trade-offs, this walkthrough on how to automate PDF text extraction with Python is a useful companion read.
Practical rule: Don't start with OCR just because a PDF looks messy. First verify whether the file has a text layer. OCR adds complexity you may not need.
A fast decision rule I use looks like this:
- Start with pdfplumber when you need text and want visibility into page layout.
- Use PyPDF2 when the PDF is simple and you want minimal code.
- Reach for Camelot when your target is rows and columns, not prose.
- Switch to pytesseract only when normal text extraction returns empty or unusable output.
Extracting Plain Text from Digital PDFs
For digital PDFs, the boring solution is often the right one. Start with a text-layer extractor, iterate per page, and keep your cleanup separate from your extraction.

A practical pdfplumber baseline
import pdfplumber
def extract_text(pdf_path: str) -> str:
parts = []
with pdfplumber.open(pdf_path) as pdf:
for page_number, page in enumerate(pdf.pages, start=1):
page_text = page.extract_text() or ""
parts.append(page_text)
return "\n".join(parts)
text = extract_text("document.pdf")
print(text[:2000])
This works well when the PDF was digitally generated and contains a proper text layer. For that class of documents, tools like PyPDF2 and pdfplumber can achieve near-100% text extraction accuracy, but they fail completely on scanned, image-only documents, based on the practical comparison in this write-up on Python PDF extraction methods.
That “works well” still comes with conditions. Plain extraction gives you text, not necessarily clean reading order.
Cleaning the output before it bites you
The first cleanup problem is usually broken spacing and extra blank lines. A small post-processing pass goes a long way.
import re
import pdfplumber
def extract_and_clean_text(pdf_path: str) -> str:
chunks = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
text = page.extract_text() or ""
chunks.append(text)
raw_text = "\n".join(chunks)
# Collapse excessive blank lines
cleaned = re.sub(r'\n\s*\n', '\n', raw_text)
return cleaned.strip()
text = extract_and_clean_text("document.pdf")
print(text)
That regex pattern, re.sub(r'\n\s*\n', '\n', text), is a practical fix for the extraneous line breaks that often show up in extracted text, and it comes directly from the same Seattle Data Guy comparison linked above.
For encoding issues, keep a fallback nearby:
def try_fix_encoding(text: str) -> str:
try:
return text.encode('latin1').decode('utf8')
except Exception:
return text
A few gotchas show up repeatedly in production:
- Multi-column pages: The extractor may interleave columns. If page order matters, inspect coordinates instead of trusting raw output.
- Headers and footers: Repeated page furniture pollutes downstream chunking and search.
- Flattened tables: Numbers from adjacent columns can collapse into one line.
- Metadata confusion: Sometimes what you need isn't page text at all. If you're debugging titles, authors, or document properties, this guide on secure PDF metadata analysis for developers is a solid reference.
If your text extractor returns an empty string on every page, stop tweaking regex. You probably don't have a text problem. You have an OCR problem.
Pulling Tables into Pandas DataFrames
Text extraction and table extraction are different jobs. If your real output is CSV, Excel, or line items in a DataFrame, a text-first pipeline usually creates more cleanup work than it saves.

Camelot works when the PDF actually contains a table shape
Camelot is the classic choice when the PDF has a visible table structure and a real text layer.
Use lattice when the table has ruling lines. Use stream when the table is defined mostly by spacing.
import camelot
# Tables with visible cell borders
tables = camelot.read_pdf("statement.pdf", pages="1", flavor="lattice")
for i, table in enumerate(tables):
df = table.df
print(f"Table {i + 1}")
print(df.head())
And for whitespace-defined tables:
import camelot
tables = camelot.read_pdf("report.pdf", pages="1", flavor="stream")
for table in tables:
df = table.df
print(df)
A pattern that works well in apps:
- Try lattice first for ruled financial tables.
- If it misses rows or splits cells badly, retry with stream.
- Normalize headers and trim junk rows before writing to CSV or Excel.
Common failure points are predictable:
- Merged cells: Camelot may duplicate or shift values.
- Multi-page tables: You often need to concatenate page-level DataFrames yourself.
- Intro text above the table: The parser may treat nearby labels as part of the first row.
- Scanned bank statements: Camelot won't save you if the PDF is image-only.
If your end goal is spreadsheet output, it's worth sanity-checking your DataFrame against the final export format early. For teams building a pdf to csv or bank statement to excel flow, this kind of output validation matters more than elegant parser code. A practical destination format to benchmark against is a tool like PDF to Excel conversion.
Here's a slightly more realistic example:
import camelot
import pandas as pd
tables = camelot.read_pdf("invoice.pdf", pages="all", flavor="lattice")
frames = []
for table in tables:
df = table.df
frames.append(df)
if frames:
combined = pd.concat(frames, ignore_index=True)
print(combined.head())
combined.to_csv("invoice_tables.csv", index=False)
For a visual walkthrough of table extraction workflows, this demo is worth a look:
<iframe width="100%" style="aspect-ratio: 16 / 9;" src="https://www.youtube.com/embed/yKAuUAPREMw" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
A fallback for harder tables
Some tables aren't really “tables” from the parser's point of view. They're mixed layout regions, nested cells, image-backed rows, or awkward financial statements where borders and alignment aren't reliable.
That's where a CV-plus-OCR approach starts to outperform classic parsers. Unstructured's partition_pdf(..., strategy='hi_res') combines layout detection, object detection, and OCR, and its 'hi_res' mode reached 88-95% F1-scores on benchmarks, compared with about 65% for simpler libraries, while preserving table structure as HTML that you can load with pd.read_html(html), according to Unstructured's guide to processing PDFs with hi_res table extraction.
That matters when you need structure, not just cell text. A practical pattern looks like this:
import pandas as pd
from unstructured.partition.pdf import partition_pdf
elements = partition_pdf(
filename="financial_report.pdf",
strategy="hi_res",
infer_table_structure=True
)
tables = [el for el in elements if el.category == "Table"]
for el in tables:
html = el.metadata.text_as_html
dfs = pd.read_html(html)
for df in dfs:
print(df.head())
Don't judge a table extractor by whether it finds text. Judge it by whether the rows still mean the same thing after import.
Handling Scanned PDFs with OCR
You run extract_text() on a vendor invoice and get an empty string. The file opens fine. The parser does not.
That usually means you are looking at page images, not a real text layer. At that point, stop trying different text parsers. Route the document to OCR.
Scanned PDFs are a different problem class from digital PDFs. The tool choice changes with it. For text-based PDFs, use a parser that reads the embedded text layer. For scans, render each page to an image and run OCR on the result. That split is one of the highest-value decisions in a production pipeline because it avoids wasting CPU on clean digital files and avoids wasting debugging time on scans that will never work with PyPDF2 or pdfplumber.
Detect scans early
A simple heuristic is enough for many pipelines. Try native extraction on the first page. If you get no text, or only a handful of junk characters, treat the file as scanned and switch paths.
A two-step OCR pipeline in Python
Step one converts PDF pages into images. Step two sends those images to Tesseract through pytesseract.
from pdf2image import convert_from_path
import pytesseract
def ocr_pdf(pdf_path: str) -> str:
images = convert_from_path(pdf_path)
parts = []
for i, image in enumerate(images, start=1):
text = pytesseract.image_to_string(image)
parts.append(text)
return "\n".join(parts)
text = ocr_pdf("scanned.pdf")
print(text[:2000])
That works, but the defaults are not always good enough. OCR quality depends heavily on scan resolution, contrast, rotation, and page noise. If the source came from a copier, phone camera, or fax export, basic preprocessing usually improves results.
from pdf2image import convert_from_path
from PIL import ImageOps
import pytesseract
def ocr_pdf_preprocessed(pdf_path: str) -> str:
images = convert_from_path(pdf_path)
pages = []
for image in images:
gray = ImageOps.grayscale(image)
text = pytesseract.image_to_string(gray)
pages.append(text)
return "\n".join(pages)
In practice, I would also tune DPI at render time and set Tesseract options for the document type. A dense multi-column report, a receipt, and a typed form often need different OCR settings.
Common failure points before you hit production
The hard part is not calling OCR. The hard part is getting stable output across messy inputs.
- Low-resolution scans: OCR falls off fast when the original scan is blurry or compressed.
- Skewed or rotated pages: A few degrees of tilt can break line detection and word grouping.
- Mixed PDFs: Some files contain a real text layer on one page and only images on the next.
- Handwriting and stamps: General OCR handles typed text much better than handwritten notes or overlaid seals.
- Throughput: OCR is slower and more expensive than native text extraction, especially on long PDFs.
A good default is hybrid routing. Try native extraction first. Send only failed or image-only pages to OCR. That keeps the pipeline fast on clean digital files and still gives you coverage for scans.
If you want a quick sanity check before wiring this into code, test a few files with an OCR tool for scanned PDFs. It is a fast way to confirm whether the problem is your parser choice or the source document quality.
OCR is the fallback for image-based PDFs. It should not be the first step for every file.
When to Stop Building and Use an API
At small scale, a Python script is fine. At product scale, PDF extraction turns into a maintenance problem.
Not because the code is hard to write. Because every new document class adds another branch: digital or scanned, single-column or multi-column, lattice table or stream table, fixed layout or drifting layout, encrypted or malformed, statement or invoice, clean text layer or weird glyph encoding. The script keeps growing until nobody wants to touch it.
The maintenance cost shows up before the parsing cost
The hidden work usually looks like this:
- Library sprawl: One tool for text, another for tables, another for OCR.
- Routing logic: You need document detection before extraction starts.
- Cleanup code: Regex patches, column repair, header stripping, row stitching.
- Regression risk: A parser tweak that fixes one vendor statement breaks another.
That's the point where build-vs-buy becomes an engineering focus question, not a tooling preference. If PDF parsing is not your product, owning the whole parser matrix can become a distraction.
What the handoff looks like
The benefit of an API is not magic extraction. It's reducing the amount of parser-selection and post-processing code your team owns.
A minimal OkraPDF shape is upload, parse, and poll the returned job URL:
curl -X POST "https://api.okrapdf.com/v1/files" \
-H "Authorization: Bearer $OKRAPDF_API_KEY" \
-F "file=@statement.pdf"
Use the returned file_id in a parse request. Add a schema when you want structured JSON back:
curl -X POST "https://api.okrapdf.com/v1/parse" \
-H "Authorization: Bearer $OKRAPDF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"file": { "id": "doc-..." },
"parser": "textlayer",
"pages": "1",
"outputs": ["json"],
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"document_type": { "type": "string" },
"visible_text_summary": { "type": "string" }
},
"required": ["document_type", "visible_text_summary"]
}
}'
Or from JavaScript:
const documentId = "doc-..."; // returned by POST /v1/files
const parseResponse = await fetch("https://api.okrapdf.com/v1/parse", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OKRAPDF_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
file: { id: documentId },
parser: "textlayer",
pages: "1",
outputs: ["json"],
schema: {
type: "object",
additionalProperties: false,
properties: {
document_type: { type: "string" },
visible_text_summary: { type: "string" }
},
required: ["document_type", "visible_text_summary"]
}
})
});
const job = await parseResponse.json();
console.log(job.status_url);
That kind of interface changes the problem shape. Your app code handles upload, result validation, and downstream business logic. The parser-selection logic stays out of your repo.
If you only need basic extraction for a side project, keep the Python path. It's flexible and cheap to start. If you're building a SaaS feature around statements, invoices, or filings, the cost of maintaining your own parser router usually becomes obvious after the first handful of edge cases.
If you want to skip the parser juggling, try OkraPDF. You can upload once, work off a single file, and move from hosting to extraction without building a separate PDF pipeline for every document type.