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?
- Your PDFs have real text, and you want a permissive license: pdfplumber. It is MIT licensed and the fastest here, and the script below is all you need.
-
Mixed PDFs, one call, and AGPL is fine for you:
pymupdf4llmwith RapidOCR. It handled every file in under 1.3 seconds. - Mixed PDFs, and you want MIT: Docling. It costs 1.7 GB of disk and 5.3 GB of memory.
- Scans where accuracy matters most: Marker, if you have a GPU and its model license fits you. It was the best on my test scans.
- Nothing to install, or money in your tables: our Actor. It is not the most accurate, but it was the only one that flagged its own wrong numbers.
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.
- $0.006 a page, plus $0.00005 a run at the default 1 GB of memory. Pages that fail are free. 1,000 pages cost $6.
- The Apify free plan lets you try 20 pages a run and 50 a month.
- Pages are read inside the run. They are not sent to any AI service.
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
- Two-column papers, math, handwriting, long documents, and languages other than English. My test scans are one typed page and one invoice, so one good result does not prove much.
- Linux, Windows and NVIDIA GPUs. My runs were on one Mac.
-
Marker's
--use_llmmode, and Docling's other OCR engines. - MinerU, unstructured, and paid cloud parsers such as LlamaParse.
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.