PDF extraction

Extract Tables from PDF in Python: pdfplumber, Camelot & Tabula

A code-first guide to extracting tables from PDFs in Python — the open-source libraries that work, exactly where they break, and when to use an API.

July 21, 2026 6 min read okraPDF

Extracting a table from a PDF in Python looks like a solved problem right up until you try it on a real document. The clean, bordered table in a tutorial parses in three lines. The table you actually have — merged headers, wrapped cells, no ruling lines, split across two pages, or worse, a scan — is where the three-line solution falls apart.

This is an honest tour of the Python options: the open-source libraries that genuinely work, the exact document features that break each one, and when it’s worth handing the job to an API instead. All the snippets are runnable.

pdfplumber: the first thing to try

pdfplumber is the pragmatic default for born-digital PDFs — files that already have a real text layer. It’s pure Python, has no Java dependency, and its table detection is good on tables with visible rules.

import pdfplumber

with pdfplumber.open("report.pdf") as pdf:
    page = pdf.pages[0]
    for table in page.extract_tables():
        for row in table:
            print(row)

extract_tables() returns a list of tables, each a list of rows, each row a list of cell strings. For a single known table, page.extract_table() returns just the first one. When detection is off, you tune table_settings — the two levers that matter most are the strategies:

table = page.extract_table({
    "vertical_strategy": "lines",   # use ruled lines to find columns
    "horizontal_strategy": "text",  # infer rows from text alignment
})

"lines" keys off the actual drawn rules; "text" infers structure from how the words line up. Mixing them handles the common case of a table with horizontal rules but no vertical ones. pdfplumber also gives you the raw words with coordinates (page.extract_words()), which is your escape hatch when automatic detection gives up and you need to reconstruct columns from x-positions yourself.

Where it breaks: no text layer at all (scanned pages return nothing — there’s no text to find), borderless tables where the strategies guess column boundaries wrong, and cells whose content wraps across multiple visual lines, which often come back split into separate rows.

Camelot: bordered tables, done well

Camelot is built specifically for tables and gives you two explicit modes, which is its whole value proposition — you tell it what kind of table you have.

import camelot

# lattice: the table has ruled lines (borders)
tables = camelot.read_pdf("report.pdf", pages="1", flavor="lattice")

# stream: no borders — infer columns from whitespace gaps
# tables = camelot.read_pdf("report.pdf", pages="1", flavor="stream")

print(tables[0].df)              # a pandas DataFrame
print(tables[0].parsing_report)  # accuracy + whitespace diagnostics
tables[0].to_csv("out.csv")

lattice finds cells from the drawn grid lines and is excellent when a table has real borders. stream reconstructs columns from whitespace and is what you fall back to for borderless tables — with the caveat that whitespace is a weaker signal than a ruled line, so you often pass explicit columns (x-coordinate separators) and a table_areas bounding box to steer it. The parsing_report accuracy score is genuinely useful in a pipeline: low accuracy is a reliable “route this page to review” trigger.

Where it breaks: Camelot needs a text layer (scanned PDFs need OCR first), stream mode gets shaky on irregular column spacing, and, like every geometric parser, it struggles with cells that span multiple rows or columns.

Tabula: reliable, if you’re okay with Java

tabula-py wraps the mature Tabula engine. It’s battle-tested — it’s the tool behind a lot of newsroom and civic-data work — with one dependency cost: it needs a Java runtime.

import tabula

# returns a list of pandas DataFrames, one per detected table
dfs = tabula.read_pdf("report.pdf", pages="all")
for df in dfs:
    print(df.head())

# or write CSVs directly
tabula.convert_into("report.pdf", "out.csv", output_format="csv", pages="all")

Like Camelot, it offers a lattice and a stream mode (lattice=True / stream=True), and it’s a solid choice when you want a stable, well-worn extractor and the JVM isn’t a problem in your environment.

Where it breaks: same fundamental limits — no OCR for scans, and multi-line or merged cells confuse row/column inference.

Where all three break — and why

Notice the pattern. Every one of these libraries is a geometric parser: it locates text with coordinates and infers table structure from lines and whitespace. That model is fast, free, and deterministic, and for born-digital PDFs with clean tables it’s the right tool — reach for it first. But the model has hard edges that are worth naming, because they’re the same edges no amount of parameter tuning will move:

  • Scanned or image-only PDFs. There is no text layer — just pixels. Geometric parsers return nothing. You need OCR to turn the image into text before any table logic can run.
  • Borderless tables. With no ruled lines, column boundaries are inferred from whitespace, and real documents have inconvenient spacing. Expect to hand-tune column positions per template.
  • Multi-line cells. A cell whose text wraps to two lines frequently splits into two rows, quietly corrupting every downstream row.
  • Merged and spanning headers. A header cell spanning three columns has no clean geometric representation and tends to smear across the output.
  • Tables split across pages. A table continuing onto page two is detected as two separate tables; stitching them back together is on you.
  • Rotated or multi-column pages. Reading order and column detection both degrade.

The honest takeaway: for clean, born-digital tables, the open-source libraries are excellent and you should use them — they’re free and they run locally. The cost shows up when your document set is mixed: some born-digital, some scanned, some borderless, some multi-page. That’s where per-template tuning stops scaling and you want something that handles OCR, layout, and structure in one pass.

The API route: hand the hard cases to okraPDF

When the documents are messy or you don’t control their shape, a parsing API earns its keep — it runs OCR when there’s no text layer, recovers table structure, and returns each value with a page number and bounding box so you can trace it back. Here’s the okraPDF path in plain requests: upload the file, kick off a parse, poll until it’s done.

import requests
import time

API = "https://api.okrapdf.com/v1"
headers = {"Authorization": f"Bearer {OKRA_API_KEY}"}

# 1. Upload the PDF
with open("report.pdf", "rb") as f:
    file_id = requests.post(
        f"{API}/files", headers=headers, files={"file": f}
    ).json()["file_id"]

# 2. Start a parse job (structured JSON output)
job = requests.post(f"{API}/parse", headers=headers, json={
    "file": {"id": file_id},
    "outputs": ["json"],
}).json()

# 3. Poll the job until it finishes
status_url = job["status_url"]
while True:
    result = requests.get(status_url, headers=headers).json()
    if result["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)

The parsed result comes back as structured blocks — including tables — with page and bounding-box coordinates on every value, so a suspicious number can be traced straight back to where it sits on the page. Because OCR runs when a page has no text layer, the same call works on scanned statements and born-digital reports without you branching on document type. If you’d rather stay on the command line, the okraPDF CLI wraps the same engine: okra extract report.pdf --schema tables.schema.json returns rows shaped to a schema you define.

Want to see the output before writing any code? Drop a PDF into the free PDF to JSON tool and watch the tables come back as structured data — right in your browser, no signup to try it.

Pick the right tool for the document

There’s no single winner here, and any post that tells you otherwise is selling something. Start with pdfplumber for born-digital PDFs; it’s the fastest path and it’s free. Reach for Camelot or Tabula when you want explicit lattice/stream control over bordered tables. Move to an API when your documents include scans, borderless tables, or shapes you don’t control — the cases where per-template tuning stops paying off.

For the broader question of pulling all kinds of data out of PDFs in Python — text, key-value fields, and tables together — see our companion guide on extracting data from PDFs in Python.