Intermediate

You have a folder of PDFs, Word documents, and PowerPoint decks that you need to feed into an LLM pipeline — a RAG system, a document summarizer, or a knowledge base builder. The problem is that LLMs work best with plain text, and most documents are packed with binary formatting, embedded fonts, and layout metadata that the model cannot interpret. Sending a raw PDF to an LLM is like handing someone a ZIP file and asking them to read it.

Microsoft’s markitdown library solves this by converting dozens of file formats — PDF, DOCX, PPTX, XLSX, HTML, CSV, EPUB, images, and audio — into clean Markdown that any LLM can process. It is a single pip install, works from Python code or the command line, and handles the conversion pipeline so your code does not have to. The Markdown output preserves headings, tables, and code blocks in a structured format that models handle especially well.

This article covers everything you need to get documents into your AI pipelines with MarkItDown. You will learn how to install it, convert individual files and entire directories, work with the Python API, handle different file types, and build a real document-processing pipeline that prepares files for a RAG system. By the end you will have a working utility that accepts any folder of mixed-format documents and outputs a structured Markdown dataset ready for embedding.

MarkItDown: Quick Example

Before diving into the details, here is the fastest path from a file to Markdown text you can feed into an LLM. This example converts an HTML page to Markdown in four lines of Python.

# quick_markitdown.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("https://example.com")
print(result.text_content[:500])

Output:

Example Domain
==============

This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.

[More information...](https://www.iana.org/domains/reserved)

The MarkItDown() constructor creates a converter instance, and convert() accepts a file path, a URL, or a file-like object. The result is a DocumentConverterResult object — result.text_content holds the clean Markdown string ready to pass into any LLM or text processing pipeline.

The real power emerges when you need to handle PDFs, Office files, and mixed directories at scale. The sections below cover all of that, starting with installation.

What Is MarkItDown and Why Use It?

MarkItDown is an open-source Python library from Microsoft that acts as a universal document-to-Markdown converter. Think of it as a universal adapter — on one end you plug in a document in almost any format, and on the other end you get clean, structured Markdown text. The library was built specifically with LLM use cases in mind: the Markdown output is structured in a way that helps models understand document hierarchy (headings, tables, code blocks) rather than receiving a flat blob of text.

The key difference from alternatives like pypdf2 or python-docx is breadth and consistency. Those libraries require a different API for each format, handle only one format each, and return raw extracted text with no structure. MarkItDown gives you one API that handles all formats and preserves semantic structure as Markdown.

FormatMarkItDownpypdf2 / python-docx / openpyxl
PDFYespypdf2 only
DOCXYespython-docx only
PPTXYespython-pptx only
XLSXYesopenpyxl only
HTML / URLYesNo
EPUBYesNo
CSV / JSONYesNo
Images (OCR)Yes (optional)No
Audio (transcription)Yes (optional)No
Unified output formatMarkdownRaw text / format-specific objects

The optional image OCR and audio transcription require extra dependencies (and an LLM API key for the best results), but the core document converters — PDF, Office, HTML — work completely offline with no API key required. That makes MarkItDown an excellent fit for any pipeline where you control the infrastructure.

Installing MarkItDown

MarkItDown is on PyPI. A basic install that handles PDFs, Office documents, and HTML covers most use cases and has minimal dependencies.

# Install the base package
pip install markitdown

# For PDF support (pdfminer.six is included in most installs)
pip install markitdown[pdf]

# For all optional extras (OCR, audio, Azure Document Intelligence)
pip install markitdown[all]

Output:

Successfully installed markitdown-0.1.1 pdfminer.six-20221105 ...

The [pdf] extra pulls in pdfminer.six for text-based PDF extraction. If you need image OCR within PDFs, you will also need an LLM plugin (covered in the advanced section). For most document-to-Markdown pipelines the base install is all you need — it handles DOCX, PPTX, XLSX, HTML, CSV, JSON, and EPUB without any extras.

Python character with floating document icons funneling into Markdown symbol
One API to rule them all. Finally.

Converting Files, URLs, and Streams

The MarkItDown class is the main entry point. It auto-detects the file format based on extension or MIME type, so you call the same method regardless of what you are converting.

Converting a Local File

Pass any local file path as a string. MarkItDown detects the format automatically and returns a DocumentConverterResult.

# convert_file.py
from markitdown import MarkItDown

md = MarkItDown()

# Convert a DOCX file
result = md.convert("report.docx")
print(f"Title: {result.title}")
print(f"Characters: {len(result.text_content)}")
print()
print(result.text_content[:300])

Output:

Title: Q3 Sales Report
Characters: 4821

# Q3 Sales Report

**Prepared by:** Finance Team
**Date:** September 30, 2025

## Executive Summary

Revenue grew 12% year-over-year driven by the enterprise segment...

The result.title property extracts the document title from metadata where available (DOCX, PDF). The text_content property always holds the full Markdown string. Notice that the headings from the Word document are preserved as Markdown heading levels — this is exactly the structural information that LLMs use to understand document hierarchy.

Converting a URL

Pass a URL string and MarkItDown fetches the page and converts the HTML to Markdown. This works especially well for documentation pages, Wikipedia articles, and any page with semantic HTML structure.

# convert_url.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("https://docs.python.org/3/library/json.html")

print(result.text_content[:600])

Output:

json --- JSON encoder and decoder
=================================

**Source code:** [Lib/json/__init__.py](https://github.com/python/cpython/tree/3.13/Lib/json/__init__.py)

JSON (JavaScript Object Notation), specified by [RFC 7159](https://datatracker.ietf.org/doc/html/rfc7159.html)...

## json.dumps(obj, *, skipkeys=False, ...)

Serialize obj to a JSON formatted str...

Navigation menus, footers, and sidebars are typically stripped out, leaving the actual content. This makes URL conversion a fast way to pull documentation into a RAG corpus without building a custom scraper for every site.

Converting from a File-Like Object

When you receive a file as bytes (from an API response, an email attachment, or a web upload), you can convert it directly from a BytesIO object without writing it to disk first.

# convert_stream.py
import io
from markitdown import MarkItDown
import requests

md = MarkItDown()

# Download a PDF into memory and convert without saving to disk
response = requests.get("https://www.w3.org/WAI/WCAG21/wcag21.pdf")
pdf_stream = io.BytesIO(response.content)

# Must pass extension hint when using streams -- no filename to detect from
result = md.convert(pdf_stream, file_extension=".pdf")
print(f"Converted {len(result.text_content)} characters from in-memory PDF")
print(result.text_content[:400])

Output:

Converted 87432 characters from in-memory PDF

Web Content Accessibility Guidelines (WCAG) 2.1
================================================

W3C Recommendation 05 June 2018

Abstract
--------

Web Content Accessibility Guidelines (WCAG) 2.1 covers a wide range of...

The file_extension parameter is required when passing a stream because there is no filename to inspect. Always include it for streams to ensure the correct converter is selected.

Developer character routing a byte stream from server to document
BytesIO: no temp files, no disk I/O, no drama.

Format-Specific Conversion Options

Most formats work out of the box, but a few have options worth knowing about. PowerPoint files get per-slide conversion, Excel files convert to Markdown tables, and PDFs expose page-level control.

Excel and CSV to Markdown Tables

Excel sheets are converted to Markdown tables — one table per worksheet. This is particularly useful when feeding structured data to LLMs that need to reason about tabular information.

# convert_excel.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("sales_data.xlsx")
print(result.text_content)

Output (example with a two-sheet workbook):

## Sheet1

| Month | Revenue | Units |
|-------|---------|-------|
| Jan   | 84000   | 420   |
| Feb   | 91200   | 456   |
| Mar   | 78500   | 392   |

## Sheet2

| Region | Manager | Target |
|--------|---------|--------|
| East   | Alice   | 100000 |
| West   | Bob     | 95000  |

Each worksheet becomes an H2 section followed by a Markdown table. LLMs handle this format well for question-answering tasks — the model can identify column headers, filter by region, and compute simple aggregations when the data is in this structured Markdown table format rather than raw CSV text.

PowerPoint Slide Extraction

PPTX files are converted slide by slide. Each slide becomes a section with its title as a heading and the body text extracted below it. Speaker notes are included when present.

# convert_pptx.py
from markitdown import MarkItDown

md = MarkItDown()
result = md.convert("product_roadmap.pptx")

# Show first 500 chars to see the structure
print(result.text_content[:500])

Output:

## Slide 1: Product Roadmap 2026

Vision: Ship the AI-native workflow layer by Q3.

- Q1: Core infrastructure and auth
- Q2: Integration layer + partner APIs
- Q3: Public launch
- Q4: Enterprise tier

**Notes:** Emphasize the partnership angle -- this is key differentiator vs competitors.

## Slide 2: Q1 Milestones

- Authentication service -- 95% complete
- Database migration -- in progress
...

Speaker notes appear as bold “Notes:” blocks after each slide’s content. For meeting-notes pipelines or sales-deck summarizers this is especially useful — you get the full context that the presenter intended to convey, not just the bullet points on the slide.

Developer character stacking slide panels into a Markdown scroll
Speaker notes included. Your LLM finally hears what the presenter was thinking.

Batch Converting a Directory

Real pipelines rarely deal with a single file. Here is a pattern to convert every supported file in a directory, log any failures, and save all results to a single Markdown file for easy embedding.

# batch_convert.py
import os
from pathlib import Path
from markitdown import MarkItDown

SUPPORTED = {".pdf", ".docx", ".pptx", ".xlsx", ".html", ".csv", ".json", ".epub"}

def batch_convert(input_dir: str, output_path: str) -> dict:
    """Convert all supported files in input_dir to a single Markdown file."""
    md = MarkItDown()
    results = {"success": [], "failed": []}
    output_parts = []

    for fpath in sorted(Path(input_dir).rglob("*")):
        if fpath.suffix.lower() not in SUPPORTED:
            continue

        print(f"Converting: {fpath.name}")
        try:
            result = md.convert(str(fpath))
            # Separate each document with a clear section header
            section = f"\n\n---\n## Document: {fpath.name}\n\n{result.text_content}"
            output_parts.append(section)
            results["success"].append(str(fpath))
        except Exception as exc:
            print(f"  FAILED: {exc}")
            results["failed"].append({"file": str(fpath), "error": str(exc)})

    with open(output_path, "w", encoding="utf-8") as f:
        f.write("# Document Corpus\n")
        f.write(f"Converted {len(results['success'])} documents.\n")
        f.write("".join(output_parts))

    return results


if __name__ == "__main__":
    stats = batch_convert("./docs", "corpus.md")
    print(f"\nDone: {len(stats['success'])} converted, {len(stats['failed'])} failed")

Output:

Converting: annual_report.pdf
Converting: onboarding.docx
Converting: roadmap.pptx
Converting: q3_data.xlsx
  FAILED: File appears to be encrypted

Done: 3 converted, 1 failed

The try/except around each conversion is essential — encrypted PDFs, corrupted files, or unsupported subtypes will raise exceptions rather than silently producing empty output. The pattern above logs the failure and continues with the rest of the batch, which is the right behavior for any automated pipeline. The output corpus.md contains all documents separated by clear section dividers that chunk-based RAG systems can split on.

Using MarkItDown with LLM Pipelines

The most common use case is feeding converted documents into an LLM. Here is how to wire MarkItDown into an OpenAI-compatible pipeline for a simple document Q&A pattern.

# doc_qa.py
from markitdown import MarkItDown
from openai import OpenAI

def answer_from_document(file_path: str, question: str, api_key: str) -> str:
    """Convert a document to Markdown and answer a question about it."""
    md = MarkItDown()
    result = md.convert(file_path)
    doc_text = result.text_content

    # Truncate if the document exceeds a safe context window size
    max_chars = 80000  # ~20K tokens for most models
    if len(doc_text) > max_chars:
        doc_text = doc_text[:max_chars] + "\n\n[Document truncated...]"

    client = OpenAI(api_key=api_key)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "You are a document analyst. Answer questions based only on the provided document.",
            },
            {
                "role": "user",
                "content": f"Document:\n\n{doc_text}\n\nQuestion: {question}",
            },
        ],
    )
    return response.choices[0].message.content


# Example usage
answer = answer_from_document(
    "contract.pdf",
    "What is the termination clause?",
    api_key="your-openai-api-key"
)
print(answer)

Output:

Section 12.3 of the contract states that either party may terminate this agreement
with 30 days written notice. Termination for cause requires only 5 business days
notice and must be accompanied by written documentation of the breach.

This pattern works with any OpenAI-compatible API — swap the base URL and API key for Anthropic, Gemini, or a local Ollama endpoint and the pattern is identical. The key insight is that MarkItDown’s Markdown output is substantially better context than raw extracted text because the model can use heading levels and table structure to locate specific sections of long documents.

Developer character passing Markdown through a portal to an LLM speech bubble
Raw PDF to LLM: garbled. Markdown to LLM: actually works.

Real-Life Example: Building a Document Corpus Preparer for RAG

This project ties together everything from this article. It accepts a folder of mixed-format documents, converts all of them to Markdown, chunks the output into LLM-ready segments, and saves a JSON file that a vector database like Chroma or Pinecone can ingest directly.

# rag_preparer.py
import json
import os
from pathlib import Path
from markitdown import MarkItDown

SUPPORTED = {".pdf", ".docx", ".pptx", ".xlsx", ".html", ".csv", ".epub"}
CHUNK_SIZE = 1500   # characters per chunk (~375 tokens)
CHUNK_OVERLAP = 150 # overlap so context is not lost at boundaries


def chunk_text(text: str, size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list:
    """Split text into overlapping chunks."""
    chunks = []
    start = 0
    while start < len(text):
        end = start + size
        chunk = text[start:end]
        # Try to end on a paragraph boundary
        boundary = chunk.rfind("\n\n")
        if boundary > size // 2:
            chunk = chunk[:boundary]
        chunks.append(chunk.strip())
        start += len(chunk) - overlap
    return [c for c in chunks if len(c) > 50]  # drop tiny trailing chunks


def prepare_rag_corpus(docs_dir: str, output_path: str) -> None:
    md = MarkItDown()
    corpus = []
    chunk_id = 0

    for fpath in sorted(Path(docs_dir).rglob("*")):
        if fpath.suffix.lower() not in SUPPORTED:
            continue

        print(f"Processing: {fpath.name}")
        try:
            result = md.convert(str(fpath))
            chunks = chunk_text(result.text_content)
            for i, chunk in enumerate(chunks):
                corpus.append({
                    "id": f"chunk_{chunk_id:05d}",
                    "source_file": fpath.name,
                    "source_title": result.title or fpath.stem,
                    "chunk_index": i,
                    "total_chunks": len(chunks),
                    "text": chunk,
                })
                chunk_id += 1
            print(f"  -> {len(chunks)} chunks")
        except Exception as exc:
            print(f"  FAILED: {exc}")

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(corpus, f, indent=2, ensure_ascii=False)

    print(f"\nCorpus saved: {len(corpus)} chunks from {chunk_id} total across all docs")
    print(f"Output: {output_path}")


if __name__ == "__main__":
    prepare_rag_corpus("./knowledge_base", "rag_corpus.json")

Output:

Processing: company_handbook.pdf
  -> 47 chunks
Processing: product_specs.docx
  -> 18 chunks
Processing: roadmap_2026.pptx
  -> 9 chunks
Processing: pricing.xlsx
  -> 3 chunks

Corpus saved: 77 chunks from 77 total across all docs
Output: rag_corpus.json

The output rag_corpus.json is ready to embed. Each chunk carries its source file and title as metadata, so your vector database can filter by document when answering queries that should be scoped to a specific source. The overlapping chunks prevent context loss at boundaries — a sentence that straddles a chunk boundary will appear in both adjacent chunks, so the model always sees complete context around any retrieved passage. To extend this project, add an embedding step using sentence-transformers or the OpenAI Embeddings API and pipe the corpus directly into your vector store of choice.

Frequently Asked Questions

Can MarkItDown convert password-protected PDFs?

No — password-protected PDFs raise an exception during conversion because the underlying pdfminer.six library cannot read encrypted content without the password. You will see a PDFPasswordIncorrect or PDFEncryptionError. The fix is to pre-decrypt the PDF before conversion using a library like pikepdf: open with the password and save a decrypted copy, then convert the decrypted file. Always wrap conversion in try/except in batch pipelines so a single locked file does not abort the entire run.

Does MarkItDown extract images from PDFs?

With the base install, MarkItDown extracts text from PDFs but skips embedded images. If your PDFs are scanned documents (images of text, not actual text layers), the base converter will return little or no content. For scanned PDFs you need to add an LLM plugin — MarkItDown supports Azure Document Intelligence and OpenAI Vision as optional backends that can OCR images within documents. Pass an llm_client and llm_model to the MarkItDown constructor to enable this. The optional dependency install is pip install markitdown[all].

How should I handle very large documents?

For documents that produce more than 100,000 characters of Markdown, pass the whole text to an LLM in a single call only if your model supports a large context window (128K+). Otherwise, use the chunking pattern from the real-life example above — split the Markdown at paragraph boundaries with a 10% overlap and embed the chunks rather than the whole document. MarkItDown itself handles arbitrarily large files without issue; the bottleneck is always the downstream LLM context window, not the conversion step.

How well does MarkItDown handle complex tables?

Simple flat tables — with consistent column counts and no merged cells — convert reliably to Markdown tables. Complex tables with merged headers, multi-row cells, or heavily styled cells may produce irregular Markdown. Excel files generally convert better than Word or PDF tables because the underlying data is already structured. If you find table output unreliable for a specific document type, consider using openpyxl or python-docx for that format and MarkItDown for everything else in the same pipeline.

Can I stream the conversion output?

MarkItDown does not support streaming output — the convert() call blocks until the full document is converted and returns the complete result. For large documents this can take several seconds, especially for multi-hundred-page PDFs. If you need non-blocking behavior in an async pipeline, wrap the conversion in asyncio.run_in_executor() to run it in a thread pool and await the result without blocking your event loop.

Can I add a custom converter for a format MarkItDown does not support?

Yes — MarkItDown has a plugin system. You can register a custom DocumentConverter subclass for any file extension. Implement the convert() method that accepts a file path and returns a DocumentConverterResult, then register it via md.register_converter(MyConverter()). This is useful for proprietary formats specific to your industry — medical records in HL7, CAD files, or internal XML schemas — where you have the parsing logic but need to plug it into the same batch pipeline that handles standard formats.

Conclusion

MarkItDown removes the most painful part of building LLM document pipelines: writing and maintaining a different parser for every file format. A single MarkItDown().convert() call handles PDFs, DOCX, PPTX, XLSX, HTML, CSV, EPUB, and more, returning clean Markdown that LLMs can reason over rather than raw binary or poorly-structured text. The real-life example above gives you a complete RAG corpus preparer that you can extend — add an embedding step, plug in a vector store, or swap in a different chunking strategy depending on your document types.

The next step is to test it on your actual document set. Run the batch converter, inspect the output Markdown for any formatting issues specific to your files, and tune the chunk size to match your embedding model’s token limit. For OCR-heavy or audio transcription pipelines, explore the optional llm_client parameter to unlock MarkItDown’s full feature set.

Official documentation and source: github.com/microsoft/markitdown. PyPI package: pypi.org/project/markitdown/.