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.

May 13, 2026 14 min read OkraPDF

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

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.

A confused person staring at a messy document sketch while thinking about extracting text from PDFs.

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:

  1. 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.
  1. Scanned PDF

The page is an image. Text extraction returns empty strings or garbage. You need OCR.

  1. 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.

An infographic showing top Python libraries for various PDF tasks like text extraction, layout analysis, and manipulation.

Quick comparison

LibraryPrimary Use CaseHandles Scanned PDFs?Key AdvantageKey Limitation
PyPDF2Basic text extraction from digital PDFsNoSimple API and quick to startWeak on scans and layout fidelity
pdfminer.sixLower-level text and layout extractionNoMore control over parsing detailsMore setup and more verbose code
pdfplumberLayout-aware text extraction and inspectionNoEasier debugging for positions and page structureStill depends on an existing text layer
CamelotTable extraction into DataFramesNoGood fit for explicit table layoutsNot useful for scanned pages without OCR
pytesseractOCR for scanned PDFs after image conversionYesReads image-only pagesSlower, 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 digital graphic displaying pdfplumber text extraction output with four example lines of extracted sample content.

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.

A diagram illustrating the transformation of an unstructured PDF table into a clean Pandas DataFrame using Python.

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:

  1. Try lattice first for ruled financial tables.
  2. If it misses rows or splits cells badly, retry with stream.
  3. 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.