Independent guide. Not affiliated with the pdfplumber project or its maintainers.Official repository: jsvine/pdfplumber ↗

Start

pdfplumber tutorial

Nine short steps, two sample PDFs, real output at every step. By the end you will have pulled text, coordinates and tables out of a PDF, and fixed a table that the default settings miss.

Tested with pdfplumber 0.11.10 · Python 3.13Updated 2026-11-04Source of truth: official README ↗

Before you start

You need Python 3.10 or newer and pdfplumber installed (python -m pip install pdfplumber, details in the install guide). Download the two sample PDFs into a folder called samples/ next to your script:

Both are fictional and free to reuse (CC0). Every output below was produced by running the code shown, unchanged.

1. Open a PDF and look at it

pdfplumber.open() takes a path or a binary file object (wrap bytes in io.BytesIO) and returns a PDF. Its pages list holds one Page per page, numbered from zero.

step1_open.py
import pdfplumber

pdf = pdfplumber.open("samples/invoice.pdf")
print(pdf.metadata["Title"])
print(len(pdf.pages), "page")

page = pdf.pages[0]
print("size:", page.width, "x", page.height, "points")
pdf.close()
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
Invoice INV-2026-0417
1 page
size: 612 x 792 points

Sizes are in points: 1/72 of an inch. 612 × 792 points is US Letter (8.5 × 11 in). Every coordinate pdfplumber gives you uses the same unit.

Use with pdfplumber.open(...) as pdf: from now on. It closes the file for you, which matters when you process thousands of PDFs.

2. Extract all the text

step2_text.py
import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    page = pdf.pages[0]
    print(page.extract_text())
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
Hollow Pine Supply Co. INVOICE
418 Quarry Road, Unit 6
Invoice no. INV-2026-0417
Millbrook, OR 97000
billing@hollowpine.example Issue date 2026-10-03
Due date 2026-11-02
PO number PO-88213
BILL TO
Larkspur Robotics GmbH
Attn: Accounts Payable
Feldstraße 12
20357 Hamburg, Germany
Item Description Qty Unit price Amount
HP-1042 Stainless hex bolts M8 x 40 (box of 100) 12 18.40 220.80
…

extract_text() groups characters into words and words into lines by their positions, then joins the lines top to bottom. Notice that the right-hand column (Invoice no., Issue date) is interleaved with the left-hand address block: both sit on the same lines of the page. To keep the visual layout, pass layout=True; to read a region on its own, crop it (step 4). The text extraction guide covers every option.

3. Get words with their positions

Text is just characters with coordinates. extract_words() returns each word with its bounding box:

step3_words.py
import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    page = pdf.pages[0]
    words = page.extract_words()
    print(len(words), "words")
    for w in words[:4]:
        print(f'{w["text"]:<10} x0={w["x0"]:6.1f}  top={w["top"]:6.1f}')
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
151 words
Hollow     x0=  54.0  top=  56.1
Pine       x0= 125.1  top=  56.1
Supply     x0= 172.9  top=  56.1
Co.        x0= 245.2  top=  56.1

Hover over the page to see what pdfplumber sees. The reading under the toolbar is your cursor position in pdfplumber’s coordinates:

x — · top —
Top of the sample invoice with every detected word outlined
The 151 words pdfplumber found on the invoice. Turn on chars to see the 851 individual characters. Source: invoice.pdf (pdfplumber.dev sample, CC0), top of page 1

x0 is the distance from the left edge, top the distance from the top edge, and x1 / bottom the far sides. With those four numbers you can select anything on the page. Characters, lines, rectangles and images carry the same keys; the objects reference lists them all.

4. Crop to the part you need

page.crop(bbox) returns a new page that only contains objects inside the box (x0, top, x1, bottom). Everything else, including text, is cut away.

step4_crop.py
import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    page = pdf.pages[0]
    # (x0, top, x1, bottom) in points, measured from the top-left corner
    bill_to = page.crop((50, 125, 300, 225))
    print(bill_to.extract_text())
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
BILL TO
Larkspur Robotics GmbH
Attn: Accounts Payable
Feldstraße 12
20357 Hamburg, Germany
x — · top —
Invoice page with the cropped Bill To region outlined
The crop box used above. Move the cursor to read coordinates for your own boxes. Source: invoice.pdf (pdfplumber.dev sample, CC0)

This is how you pull fields out of forms, invoices and statements: find the region once, then crop it on every document with the same layout. The coordinates guide has a tool that measures boxes on your own PDF.

5. Extract a table

step5_table.py
import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    page = pdf.pages[0]
    table = page.extract_table()

header, *rows = table
print(header)
for row in rows:
    print(row)
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
['Item', 'Description', 'Qty', 'Unit price', 'Amount']
['HP-1042', 'Stainless hex bolts M8 x 40 (box of 100)', '12', '18.40', '220.80']
['HP-2210', 'Aluminium extrusion 2020, 1 m', '40', '6.95', '278.00']
['HP-3307', 'Linear bearing LM8UU', '64', '1.85', '118.40']
['HP-4120', 'Timing belt GT2, 6 mm (5 m roll)', '6', '11.20', '67.20']
['HP-5001', 'Stepper motor NEMA 17, 1.5 A', '8', '14.75', '118.00']
['HP-6044', 'Cable chain 10 x 20 mm, 1 m', '5', '7.60', '38.00']
['SV-0001', 'Assembly and bench testing (hours)', '3', '65.00', '195.00']

extract_table() returns the largest table on the page as a list of rows, each a list of cell strings. It found this one by following the ruling lines that are drawn on the page:

x — · top —
Invoice line-item table with detected cells and intersections
Table cells (red) and the line intersections pdfplumber used to build them. Source: invoice.pdf (pdfplumber.dev sample, CC0), table area

Use extract_tables() (plural) when a page has more than one table, and find_tables() when you also want each table’s position. The tables guide explains how detection works.

6. Fix a table the defaults miss

Real documents are less tidy. The bank statement draws a line under every row but no lines between columns, so the default settings find nothing:

x — · top —
Bank statement where the default table finder sees only horizontal lines and finds no table
Default settings: only horizontal edges, so there are no intersections, no cells and no table. Source: bank-statement.pdf (pdfplumber.dev sample, CC0)

You could switch the columns to "vertical_strategy": "text", which infers columns from how words line up. On this page that gets close but invents an extra column at x ≈ 180, where several second words happen to start, and splits Payroll — Lark | spur Robotics. Telling pdfplumber exactly where the columns are is more reliable. The header row gives you that for free:

step6_settings.py
import pdfplumber

with pdfplumber.open("samples/bank-statement.pdf") as pdf:
    page = pdf.pages[0]
    print("default settings:", len(page.find_tables()), "tables found")

    # Rows are ruled, columns are not. Put a column boundary halfway
    # between neighbouring header words.
    names = ["Date", "Description", "Debit", "Credit", "Balance"]
    header = [w for w in page.extract_words() if w["text"] in names]
    edges = [header[0]["x0"] - 4]
    edges += [(a["x1"] + b["x0"]) / 2 for a, b in zip(header, header[1:])]
    edges += [header[-1]["x1"] + 4]

    table = page.extract_table({
        "vertical_strategy": "explicit",
        "explicit_vertical_lines": edges,
        "horizontal_strategy": "lines",
    })
    print(len(table), "rows x", len(table[0]), "columns")
    for row in table[:4]:
        print(row)
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
default settings: 0 tables found
23 rows x 5 columns
['Date', 'Description', 'Debit', 'Credit', 'Balance']
['2026-10-02', 'Pharmacy 24', '96.27', '', '2,353.73']
['2026-10-02', 'Payroll — Larkspur Robotics', '', '3,820.00', '6,173.73']
['2026-10-04', 'Cloud hosting (monthly)', '89.35', '', '6,084.38']
x — · top —
Bank statement with explicit column lines and the detected table cells
Explicit vertical lines (from the header) plus the ruled rows: every cell found. Source: bank-statement.pdf (pdfplumber.dev sample, CC0)

The same code works on pages 2 and 3, because the header repeats. This pattern, let the document tell you where the columns are, solves most borderless tables. table_settings documents every option, and borderless tables goes deeper.

7. Save the table as CSV

Rows are plain lists, so the standard csv module writes them directly. Quoting is handled for cells that contain commas:

step7_export.py
import csv
import pdfplumber

with pdfplumber.open("samples/invoice.pdf") as pdf:
    table = pdf.pages[0].extract_table()

with open("invoice-items.csv", "w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows(table)

print(open("invoice-items.csv", encoding="utf-8").read())
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
Item,Description,Qty,Unit price,Amount
HP-1042,Stainless hex bolts M8 x 40 (box of 100),12,18.40,220.80
HP-2210,"Aluminium extrusion 2020, 1 m",40,6.95,278.00
HP-3307,Linear bearing LM8UU,64,1.85,118.40
HP-4120,"Timing belt GT2, 6 mm (5 m roll)",6,11.20,67.20
…

For pandas DataFrames, Excel files with one sheet per table, and number cleaning, see export tables.

8. See what pdfplumber saw

When a result surprises you, draw it. to_image() renders the page and debug_tablefinder() overlays the table finder’s edges, intersections and cells, using the same settings as your extraction:

step8_debug.py
import pdfplumber

with pdfplumber.open("samples/bank-statement.pdf") as pdf:
    page = pdf.pages[0]
    # The settings from step 6: columns between the header words, rows from the rules
    names = ["Date", "Description", "Debit", "Credit", "Balance"]
    header = [w for w in page.extract_words() if w["text"] in names]
    edges = [header[0]["x0"] - 4]
    edges += [(a["x1"] + b["x0"]) / 2 for a, b in zip(header, header[1:])]
    edges += [header[-1]["x1"] + 4]
    settings = {"vertical_strategy": "explicit", "explicit_vertical_lines": edges,
                "horizontal_strategy": "lines"}

    im = page.to_image(resolution=100)
    im.debug_tablefinder(settings)
    im.save("bank-statement-debug.png")
    print("saved bank-statement-debug.png", im.original.size)
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
saved bank-statement-debug.png (827, 1170)

Open the PNG and you will see the picture from step 6. The interactive figures on this site are built from exactly this data. More drawing methods are in visual debugging.

9. Process every page and every file

Loop over pdf.pages for multi-page documents and over files for batches. Real folders contain files that cannot be opened, so catch pdfplumber’s exception per file and keep going. Here is a summary of the site’s whole sample folder:

step9_batch.py
from pathlib import Path
import pdfplumber
from pdfplumber.utils.exceptions import PdfminerException

for path in sorted(Path("samples").glob("*.pdf")):
    try:
        with pdfplumber.open(path) as pdf:
            n_chars = sum(len(p.chars) for p in pdf.pages)
            print(f"{path.name:22} {len(pdf.pages)} page(s)  {n_chars:>5} chars")
    except PdfminerException as e:
        # One bad file should not stop a batch: log it and move on
        print(f"{path.name:22} skipped: {type(e.args[0]).__name__}")
Outputcaptured · Python 3.13.18 · pdfplumber 0.11.10
bank-statement.pdf     3 page(s)   3122 chars
borderless-table.pdf   1 page(s)    435 chars
cid-font.pdf           1 page(s)    141 chars
cid-subset.pdf         1 page(s)    276 chars
encrypted.pdf          skipped: PDFPasswordIncorrect
form-flattened.pdf     1 page(s)    227 chars
form.pdf               1 page(s)    159 chars
framed-table.pdf       1 page(s)    244 chars
images.pdf             2 page(s)    366 chars
imperfect-tables.pdf   1 page(s)    403 chars
invoice.pdf            1 page(s)    851 chars
merged-cells.pdf       1 page(s)    330 chars
multilingual.pdf       1 page(s)    276 chars
page-boxes.pdf         1 page(s)    321 chars
report.pdf             2 page(s)   1223 chars
scanned-invoice.pdf    1 page(s)      0 chars
spacing-cases.pdf      1 page(s)    386 chars
split-rows.pdf         2 page(s)   3184 chars
styled-text.pdf        1 page(s)    277 chars
tagged-report.pdf      1 page(s)    872 chars
text-quirks.pdf        1 page(s)    485 chars
two-column.pdf         2 page(s)   9654 chars

Two files need attention. encrypted.pdf needs a password (pdfplumber.open(path, password="plumber"); see errors). scanned-invoice.pdf opens but has zero characters: it is a picture of a page with no text layer, which pdfplumber cannot read until you run OCR (scanned PDFs).