# LangChain PDF loaders compared (and a cleaner Markdown-first recipe)

> PyPDFLoader, PyMuPDFLoader, PDFPlumberLoader, UnstructuredPDFLoader — what each LangChain PDF loader actually does, which to pick, and a Markdown-first pattern that chunks better than any of them.

Published: 2026-07-18 · Source: https://marklipi.com/blog/langchain-pdf-loaders-compared


LangChain ships several PDF loaders, and they are thin wrappers around the underlying extraction libraries — so choosing a loader *is* choosing an extractor. The default `PyPDFLoader` gives you one plain-text `Document` per page, which is fine for prototypes and mediocre for retrieval: page-based splits cut through sections and flatten tables. For production RAG, either pick the loader whose underlying library matches your documents, or skip loaders entirely and go Markdown-first.

## What each loader wraps

| Loader | Underlying library | Output | Notes |
| --- | --- | --- | --- |
| `PyPDFLoader` | pypdf | Plain text, per page | Default choice; no tables |
| `PyMuPDFLoader` | PyMuPDF | Plain text, per page, fast | AGPL licensing applies |
| `PDFPlumberLoader` | pdfplumber | Text + access to table data | Slowest; best tables |
| `UnstructuredPDFLoader` | unstructured | Element-typed chunks | Heavier install; layout-aware |
| `PyPDFium2Loader` | pypdfium2 | Plain text, per page | Permissive license, decent speed |

The trade-offs are the libraries' trade-offs — speed, tables, licensing — covered in depth in [pdfplumber vs PyMuPDF vs pypdf](/blog/pdfplumber-vs-pymupdf-vs-pypdf).

## The structural problem all page loaders share

Every per-page loader hands your text splitter *pages*, but retrieval wants *sections*. A `RecursiveCharacterTextSplitter` over page text cuts mid-sentence, splits tables from headers, and embeds the page furniture (headers/footers) that makes unrelated chunks look similar.

## The Markdown-first recipe

Convert the PDF to Markdown first, then split on headings — LangChain's `MarkdownHeaderTextSplitter` exists for exactly this:

```python
import requests
from langchain_text_splitters import MarkdownHeaderTextSplitter

def load_pdf_markdown(path: str) -> str:
    with open(path, "rb") as f:
        r = requests.post("https://api.marklipi.com/convert", files={"file": f}, timeout=60)
    r.raise_for_status()  # 422 → scanned PDF, route to OCR instead
    return r.json()["markdown"]

md = load_pdf_markdown("annual-report.pdf")

splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[
    ("#", "h1"), ("##", "h2"), ("###", "h3"),
])
docs = splitter.split_text(md)  # Documents with heading metadata attached
```

What this buys you over a page loader:

- **Chunks are sections**, with the heading path in metadata — free context for embeddings and citations.
- **Tables survive** as Markdown tables inside a chunk instead of collapsing to value soup.
- **No key, no dependency choice** — the conversion is one keyless HTTP call (free tier 100/day), so there's no pypdf-vs-PyMuPDF decision or AGPL question in your `requirements.txt`.
- **Deterministic ingestion** — re-running the pipeline produces identical chunks, which makes index diffs and eval regressions tractable.

The full chunking strategy (table atomicity, size caps within sections, OCR routing) is in [the RAG parsing guide](/blog/parse-pdfs-for-rag-chunking-guide).

## When a loader is still the right call

- **Air-gapped or compliance-bound**: files can't leave the machine → `PDFPlumberLoader` (tables, MIT) or `PyMuPDFLoader` (speed, mind the AGPL).
- **Scanned documents**: no text layer to extract → `UnstructuredPDFLoader` with OCR, or a dedicated OCR step; text-layer tools including marklipi will refuse scans by design.
- **You need per-page provenance**: page-level citations are a product requirement → keep a page loader alongside, or record page offsets at conversion time.

## FAQ

### What's the best PDF loader in LangChain?

For plain prototyping, `PyPDFLoader`. For table-heavy documents, `PDFPlumberLoader`. For production RAG over digital-native PDFs, converting to Markdown and using `MarkdownHeaderTextSplitter` usually beats all page-based loaders on retrieval quality.

### Does LangChain have a native marklipi integration?

Not yet — but none is needed: the pattern above is four lines with `requests`, and the output plugs into LangChain's Markdown splitter directly.

### How do I handle scans in a LangChain pipeline?

Branch on the converter's `422` response: text-layer PDFs take the cheap deterministic path, scans route to an OCR-capable loader. [How to tell the two apart.](/blog/pdf-text-layer-vs-ocr)

