pdfplumber
pdfplumber reads a machine-generated PDF and hands you its raw geometry: every character with its font, size, and bounding box, plus every line, rectangle, curve, image, and annotation on the page. On top of that primitive layer it adds the two things people actually come for. First, a text extractor that can approximate the visual layout instead of flattening everything into one string. Second, a table finder that rebuilds a cell grid from the page's ruling lines, or from word alignment when the table has no lines at all. It can also render any page to a PNG and draw the detected edges and cells on top, which turns table debugging from guesswork into looking at a picture. It is built on pdfminer.six and inherits that library's parsing accuracy along with its speed.
The right first choice for getting structured data out of digital PDFs, and the visual debugger alone saves the hours that otherwise go into guessing why a table came out wrong. Look elsewhere if your documents are scans or if throughput matters more than fidelity.
Use it if
- You need coordinates and not just text: pulling the value that sits to the right of a label, or reading a fixed region of a form, is a crop plus an extract_text call
- You extract tables from digital PDFs that have visible ruling lines and you want a row-by-cell grid rather than a text blob
- Your tables have no ruling lines and you are willing to tune detection yourself, using the text strategy, explicit line lists, and the snap, join, and intersection tolerances
- You want to see why extraction went wrong: to_image().debug_tablefinder() draws the edges, intersections, and cells the algorithm actually found
- You need per-character font name and size to detect headings, bold runs, footnote markers, or where one visual column ends
- Your PDFs are scans. There is no OCR here at all, and the README says outright that table extraction from OCRed documents is not well supported. A scanned page returns an empty string from extract_text and nothing warns you, so you have to detect it yourself and hand the file to tesseract or ocrmypdf
- You process pages by the thousand. Parsing runs through pdfminer.six in pure Python, and the project's own comparison section states that pymupdf is substantially faster. A large batch job is minutes per document, not milliseconds
- You need to write PDFs. There is no generation, no merging, no splitting, no rotation, no watermarking, and no form filling. pypdf covers all of that and pdfplumber covers none of it
- You have other packages that depend on pdfminer.six. pdfplumber pins it to one exact build with pdfminer.six==20260107, so a conflicting requirement anywhere else in your environment is unresolvable until pdfplumber itself ships a new release
- You iterate a long document in a long-lived process. Page objects cache their parsed layout and never drop it on their own, so memory climbs page after page unless you call page.close() in the loop
- You want a settled API. After 76 releases it is still versioned 0.x, table extraction was redesigned with breaking changes in 0.5.0, and object attributes have been removed inside the 0.11 line when the pinned pdfminer.six changed underneath, as stroking_pattern was in 0.11.7
Setup reality
pip install pdfplumber pulls pdfminer.six at one exact pinned build, Pillow, and pypdfium2, which ships a prebuilt PDFium binary inside its wheel. There is no compiler step on mainstream platforms, and there is no ImageMagick or Ghostscript to install; tutorials that tell you to install those predate 0.10.0, where Wand was replaced by pypdfium2 for page rendering. The friction shows up after the install. That exact pdfminer.six pin makes pip give up if anything else in the environment wants a different version. Visual debugging is built for notebooks, so im.show() on a headless box tries to launch a desktop image viewer and accomplishes nothing; save to a file instead. Metadata that fails to parse is only a warning unless you pass strict_metadata=True. Coordinates are in PDF points measured from the top of the page, which is upside down relative to the PDF specification's own bottom-left origin, and getting that backwards is the most common reason a crop returns nothing.
Patterns
Open a PDF and read a pageopen-and-extract-text
import pdfplumber
with pdfplumber.open("report.pdf") as pdf:
print(len(pdf.pages), "pages")
page = pdf.pages[0]
print(page.extract_text())open accepts a path, bytes, or any file-like object, plus password= for encrypted files. Use it as a context manager; otherwise the file handle stays open and per-page caches are never released.
Keep visual layout, or fix run-together wordslayout-and-tolerances
# rough visual layout: spaces padded to approximate x position
print(page.extract_text(layout=True))
# words running together or splitting apart? tune the tolerance
print(page.extract_text(x_tolerance=1.5))
print(page.extract_text(x_tolerance_ratio=0.15))layout=True is still labelled experimental and pads output using x_density and y_density, so it is good for eyeballing structure and bad as a parsing target. When spacing is wrong, x_tolerance is the knob; x_tolerance_ratio scales it with font size, which helps documents that mix 8pt and 20pt text.
Pull tables off a pageextract-table
largest = page.extract_table() # row -> cell, or None
if largest:
for row in largest:
print(row)
for table in page.extract_tables(): # table -> row -> cell
print(len(table), "rows")Cells come back as strings or None, where None means the grid had a cell there but no text in it. extract_table returns None outright when nothing was found, so guard it before iterating.
Extract a table drawn with whitespaceborderless-table
settings = {
"vertical_strategy": "text",
"horizontal_strategy": "text",
"min_words_vertical": 3,
"text_x_tolerance": 2,
}
table = page.extract_table(settings)The default strategy is "lines" and finds nothing on a table with no ruling lines. "text" infers column edges from word alignment, which means it also invents columns out of ordinary paragraphs, so crop to the table region first. "lines_strict" ignores rectangle sides, useful when a shaded header row is a filled rect rather than four lines.
Read one region of the pagecrop-region
# (x0, top, x1, bottom) in points, measured from the TOP of the page
header = page.crop((0, 0, page.width, 120))
print(header.extract_text())
# keep only objects that fall entirely inside
body = page.within_bbox((40, 120, page.width - 40, page.height - 60))crop keeps objects that only partly overlap the box and slices them; within_bbox keeps only objects entirely inside; outside_bbox is the inverse. The vertical axis runs downward from the top, the opposite of the PDF specification's bottom-left origin, and mixing that up is the usual reason a crop comes back empty.
Get words with positions and font datawords-with-coordinates
words = page.extract_words(extra_attrs=["fontname", "size"])
for w in words:
if w["size"] > 14:
print(w["text"], w["x0"], w["top"], w["fontname"])extra_attrs both filters and reports: characters only join into one word when they share every listed attribute, so adding "size" splits a token wherever the font size changes. Drop the attrs if you are getting fragments where you expected whole words.
Find a label and read what sits beside itvalue-next-to-label
hits = page.search(r"Invoice\s+No", regex=True, case=False)
if hits:
box = hits[0]
right = page.within_bbox(
(box["x1"], box["top"] - 2, page.width, box["bottom"] + 2)
)
print(right.extract_text())search is an experimental feature and returns dicts carrying x0, x1, top, bottom, the regex groups, and the matched chars. Zero-width and whitespace-only matches are discarded, so a pattern that can match nothing hands you back nothing.
Look at what the table finder sawvisual-debug
im = page.to_image(resolution=150)
im.debug_tablefinder(settings)
im.save("page1-tables.png")
im2 = page.to_image()
im2.draw_rects(page.extract_words())
im2.save("page1-words.png")debug_tablefinder draws detected edges in red, intersections as circles, and tables in light blue, which is the fastest way to tell whether your strategy found the grid or hallucinated one. im.show() opens a desktop viewer and does nothing over SSH, so save a file instead.
Loop a large document without leaking memorylarge-pdf-memory
with pdfplumber.open("10k-pages.pdf") as pdf:
for page in pdf.pages:
handle(page.extract_text())
page.close() # release this page's cached layoutPage objects cache parsed layout and object lists and never drop them on their own, so a long loop climbs in memory until the process is killed. close() flushes that cache and the page reparses on demand if you touch it again.
Detect a scanned page before you trust the outputdetect-scanned-page
text = page.extract_text() or ""
if not page.chars and page.images:
raise RuntimeError("page looks scanned: OCR it first")There is no OCR in this library and no signal when a page is just a photograph of text. Checking for zero chars is the standard guard before routing the file to tesseract, ocrmypdf, or a vision model.
Fix doubled letters from fake-bold textdedupe-chars
clean = page.dedupe_chars(tolerance=1)
print(clean.extract_text())Some generators fake bold by drawing the same glyph twice a fraction of a point apart, which makes extract_text return things like "ttoottaall". dedupe_chars drops the near-duplicates first, matching on text, position, fontname, and size.
Inspect an unfamiliar PDF from the shellcli-inspect
pdfplumber report.pdf --format csv --pages 1 3-5 > objects.csv
pdfplumber report.pdf --format json --types char rect line > objects.json
pdfplumber report.pdf --format textThe CLI dumps every object it finds, which is the quickest way to see what a file you have never opened is actually made of. --pages is 1-indexed and space delimited, unlike the 0-indexed pdf.pages list you use from Python.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pymupdf | PyPI | You are processing thousands of pages and raw speed matters more than shape-level detail, and the AGPL terms or a commercial license work for you |
| pypdf | PyPI | You need to merge, split, rotate, encrypt, or fill PDFs rather than read data out of them |
| camelot-py | PyPI | Table extraction is the entire job and pdfplumber's line-based finder keeps missing your layouts |
| pdfminer.six | PyPI | You only want text and layout boxes and would rather not carry the table finder, the renderer, and Pillow |