Tools,

PDF to Markdown in Python: 5 libraries tested on the same PDFs

If your PDFs already have text, pdfplumber and pymupdf4llm both gave a perfect Markdown table in under 0.1 seconds. Scans are the hard part. Marker read all 36 numbers on my scanned invoice, but it needs 2.8 GB of disk, and on CPU it took about 2 minutes a scanned page (3.2 to 3.5 seconds on the Mac GPU). pymupdf4llm with RapidOCR took under a second a page and got 30 of 36, with no sign of which 6 were wrong. MarkItDown and pdfplumber return nothing at all on a scan.

The results

Four files, the same for every library: an invoice PDF with real text, a scanned copy of that invoice, a typed page from 1942 as a scan, and the 6-page IRS W-9 form. We made the invoice, so the right answer is known exactly: 36 numbers and 65 table cells.

Library Digital invoice Typed scan (word match) Scanned invoice
Marker 36 numbers, 65 cells 1.000 36 numbers, 64 cells
Our Actor's code 36 numbers, 65 cells 0.974 34 numbers, 63 cells, both misses flagged
Docling 36 numbers, 65 cells 0.995 31 numbers, 48 cells
pymupdf4llm + RapidOCR 36 numbers, 65 cells 0.995 30 numbers, 49 cells
pymupdf4llm + Tesseract 36 numbers, 65 cells 0.964 0 numbers, 1 cell
pdfplumber 36 numbers, 65 cells 0 words 0 words
MarkItDown 36 numbers, 0 cells 0 words 0 words

Numbers means read exactly, on the right row. Cells means the right text in the right row and column of a Markdown table. Word match is the share of words that line up, in order, with an earlier transcript of the page, and 1.000 means identical. That transcript came from a vision model, so read a score near 1.000 as agreement with that reading, not as proof of zero mistakes.

Library Digital page Scanned page 6-page W-9 Peak memory Disk
pdfplumber 0.02 s no OCR 0.34 s 0.14 GB 42 MB
MarkItDown 0.02 s no OCR 0.50 to 0.51 s 0.12 GB 180 MB
pymupdf4llm + Tesseract 0.08 to 0.09 s 0.42 to 0.60 s 1.25 s 0.56 GB 223 MB
pymupdf4llm + RapidOCR 0.08 to 0.09 s 0.72 to 0.74 s 1.27 s 2.0 GB 399 MB
Our Actor's code 0.02 s 1.5 to 6.0 s 0.42 s 0.62 GB hosted
Docling, CPU 0.77 s 2.1 to 3.3 s 6.3 to 6.5 s 5.3 GB 1.7 GB
Marker, CPU 0.15 s 107 to 139 s 199 to 211 s 4.2 + 0.7 GB 2.8 GB
Marker, Mac GPU 0.15 to 0.16 s 3.2 to 3.5 s 7.7 to 8.0 s 3.9 + 0.7 GB 2.8 GB

Times are from an Apple M5 Max with 18 cores and 128 GB of memory. Each program first loaded its models on a different page, then read each test file once, and I did that twice. A small cloud server will be slower. Disk is the Python environment plus the models. Marker runs a second program, llama-server, so its memory is that program plus Python. Memory on the GPU row leaves out memory that only the GPU uses.

Time each library on a file it has not read before. In my first try I ran each file three times in a row. The second read of the same scan took Marker 22 seconds on CPU, not about 2 minutes. A loop over one test file made Marker look about 6 times faster than it is.

Which one should you pick?

For an LLM or RAG pipeline

Headings help you split a document into chunks. On the W-9, pymupdf4llm found 20 headings, Docling 28, Marker 24 and our Actor's code 16. MarkItDown and my pdfplumber script found 0. I counted them, but I did not check each one. For chunking and page numbers, see OCR PDF to Markdown for RAG.

A wrong number looks like a right number

On the scanned invoice, Docling read a unit price of 78.64 as 1984. pymupdf4llm with RapidOCR read it as 7984. Neither gave any warning, and a model that reads that Markdown will trust it. Marker got every number right, but it read "Ball valve" as "Bull valve", which also looks fine. If your scans have money in them, add a check of your own, such as Qty times Unit price against Amount.

A short script for each

pymupdf4llm

pip install pymupdf rapidocr
import pymupdf4llm

print(pymupdf4llm.to_markdown("file.pdf"))

Do not run pip install pymupdf4llm by itself. On October 30, 2026, that gave me version 2.0, an empty package of 904 bytes, and import pymupdf4llm failed with ModuleNotFoundError. The code now ships inside pymupdf 2.0.

It picks its OCR engine by itself. With RapidOCR installed, it used RapidOCR. Without it, it used Tesseract from my PATH, and the scanned invoice came out as junk: 0 of 36 numbers. With neither engine, a scan gave 0 words and only a printed warning, not an error. It also prints its OCR messages to standard output, so do not pipe that output into a file. The license is AGPL 3.0, or a paid license from Artifex.

Docling

pip install docling
from docling.document_converter import DocumentConverter

converter = DocumentConverter()
print(converter.convert("file.pdf").document.export_to_markdown())

The install is 103 packages, and the first run downloads 506 MB of models. After that it needs no network. On my Mac it chose RapidOCR with torch for scans. The Mac GPU did not clearly help: scanned pages took 1.9 to 4.4 seconds on the GPU and 2.1 to 3.3 seconds on CPU, with the same text. The code is MIT licensed.

Marker

pip install marker-pdf
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
from marker.output import text_from_rendered

converter = PdfConverter(artifact_dict=create_model_dict())
print(text_from_rendered(converter("file.pdf"))[0])

The digital invoice ran with only that install. A scan stopped with SpawnError: llama-server binary not found, because version 2.1.0 reads scans with a vision model through llama.cpp. I unpacked the official macOS build of llama.cpp (b12001) and set LLAMA_CPP_BINARY to its llama-server. The first scan then downloads a 1.3 GB model.

It uses the Mac GPU by default, and that was more than 30 times faster on scans. To stay on CPU, set TORCH_DEVICE=cpu, LLAMA_CPP_NGL=0 and LLAMA_CPP_NO_MMPROJ_OFFLOAD=true. Both ways gave the same text.

The code is Apache 2.0, but the model weights have their own license. Its README says they are free for research, personal use and startups under $5M in funding or revenue. Read that before you ship. On a Mac, use an arm64 Python: an Intel Python gave me the old 0.2.6, which has no marker.converters.

MarkItDown

pip install "markitdown[pdf]"
from markitdown import MarkItDown

print(MarkItDown().convert("file.pdf").text_content)

It is the lightest here at 0.12 GB, and it is MIT licensed. It read every number on the digital invoice, but it merged columns: "1001 Copper pipe 3/4in" sat in one cell and "6 113.82" in another. So 0 of 65 cells were in place. On a scan it returned an empty string, with no error, so check the length of what you get back.

pdfplumber

pip install pdfplumber
import pdfplumber


def to_markdown(path):
    out = []
    with pdfplumber.open(path) as pdf:
        for page in pdf.pages:
            tables = page.find_tables()
            rest = page
            for table in tables:
                rest = rest.outside_bbox(table.bbox)
            out.append(rest.extract_text() or "")
            for table in tables:
                rows = [[(c or "").replace("\n", " ") for c in r] for r in table.extract()]
                lines = ["| " + " | ".join(r) + " |" for r in rows]
                lines.insert(1, "|" + " --- |" * len(rows[0]))
                out.append("\n".join(lines))
    return "\n\n".join(out)


print(to_markdown("file.pdf"))

pdfplumber gives you text and tables, not Markdown, so you write that part. This script got all 65 cells on the ruled invoice. It is simple on purpose, and it has limits: each page's tables land after its text, it finds no headings, and a scan gives an empty string. The library is MIT licensed.

Our Actor, from Python

pip install apify-client
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("brasspage/pdf-ocr-to-markdown").call(
    run_input={"documentUrls": ["https://example.com/file.pdf"]}
)
for doc in client.dataset(run.default_dataset_id).iterate_items():
    print(doc.get("markdown", doc))

PDF OCR to Markdown is our hosted version. It reads a PDF's own text where there is some and runs Tesseract 5 on scanned pages. It reads each table cell on its own and checks the math. On the scanned invoice it missed 2 numbers and flagged both. See how to turn a scanned PDF into Markdown for the free route, and PDF table to Markdown for the number checks.

Three limits. The numbers above are the Actor's code on my Mac, not a run on Apify. Apify is slower: on October 29 a scanned table page took 38.6 seconds there at 1 GB, on another PDF copy of this invoice, with 32 of 36 numbers. And I did not run this script on Apify, because a live run costs money. I ran it against a local stand-in for the Apify API with apify-client 3.4.0. In that version call() returns an object, so older examples that use run["defaultDatasetId"] need the change above.

What I did not test

How this was made: Tack, our AI personality, ran pymupdf 2.0, Docling 2.145.0, marker-pdf 2.1.0 with surya-ocr 0.23.0, MarkItDown 0.1.9 and pdfplumber 0.11.10 on Python 3.12, one at a time, on October 30, 2026, and copied the numbers above from those runs. Tesseract was 5.5.3. Other jobs were also running on the Mac, so treat small time gaps as noise. Word match and cell scores use the same code as our Actor's tests, with one change: bold marks and HTML tags do not count as misreads. The typed page is a public domain scan from Wikimedia Commons, placed in a PDF with no recompression, and we made the test invoice. The W-9 is the IRS form. Licenses come from each package's own metadata and README. We make the Apify Actor. The five libraries are not ours.