Blog

Convert DOCX to Markdown with Python

12 Aug 2026 · 5 min read


If you work with AI training data, document pipelines, or RAG systems, you've almost certainly dealt with DOCX files. Word documents are everywhere — research drafts, business reports, legal contracts, manuscript submissions — but they're a pain to process programmatically.

This post shows you how to convert DOCX to clean Markdown using AnyMD's REST API from Python, with async batching, error handling, and pagination for production-scale workflows.

Why Markdown from DOCX?

DOCX is a binary format (a ZIP of XML files). Libraries like python-docx can extract text, but they lose structure: headings become plain paragraphs, lists collapse into indented text, tables become tab-separated noise. Markdown preserves all of that in a format that every LLM, vector database, and static site generator can consume natively.

One-shot conversion

The simplest case — a single DOCX file:

import requests

url = "https://anymd.net/api/convert"
headers = {"Authorization": "Bearer your-api-key"}

with open("report.docx", "rb") as f:
    resp = requests.post(url, headers=headers, files={"file": ("report.docx", f)})

if resp.ok:
    markdown = resp.text
    print(f"Got {len(markdown)} chars of Markdown")
else:
    print(f"Error {resp.status_code}: {resp.text}")

AnyMD auto-detects DOCX from the file extension and returns GitHub-Flavored Markdown. Headings, numbered lists, bullet lists, bold/italic, tables, images, and hyperlinks are all preserved — exactly what you'd see if you opened the document in Word.

Async batch conversion with retries

When you have 50 or 500 DOCX files, sequential conversion is too slow. Here's an async batch processor with proper error handling:

import asyncio
import aiohttp
from pathlib import Path

API_URL = "https://anymd.net/api/convert"
HEADERS = {"Authorization": "Bearer your-api-key"}

async def convert_one(session: aiohttp.ClientSession, path: Path, out_dir: Path) -> str:
    for attempt in range(3):
        try:
            data = aiohttp.FormData()
            data.add_field("file", path.read_bytes(), filename=path.name)
            async with session.post(API_URL, headers=HEADERS, data=data) as resp:
                if resp.status == 429:
                    wait = int(resp.headers.get("Retry-After", 5))
                    await asyncio.sleep(wait)
                    continue
                if resp.ok:
                    md = await resp.text()
                    out_path = out_dir / f"{path.stem}.md"
                    out_path.write_text(md)
                    return f"✓ {path.name}"
                return f"✗ {path.name} ({resp.status})"
        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
            if attempt == 2:
                return f"✗ {path.name} — {e}"
            await asyncio.sleep(1.5 ** attempt)

async def batch_convert(docx_dir: str, out_dir: str, concurrency: int = 10):
    docx_files = list(Path(docx_dir).glob("*.docx"))
    out_path = Path(out_dir)
    out_path.mkdir(exist_ok=True)

    connector = aiohttp.TCPConnector(limit=concurrency)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = [convert_one(session, p, out_path) for p in docx_files]
        results = await asyncio.gather(*tasks)

    ok = sum(1 for r in results if r.startswith("✓"))
    print(f"{ok}/{len(results)} converted — see {out_dir}/")

asyncio.run(batch_convert("docx_input", "markdown_output"))

The script uses a connection-limited session pool (10 concurrent requests by default), retries on 429 rate-limits and transient errors with exponential backoff, and writes each result as a .md file.

Pagination for large document libraries

If you're pulling documents from an API or S3 bucket, you might need paginated processing. Here's a generator pattern:

import requests
from pathlib import Path
from typing import Generator

def document_batches(doc_dir: str, batch_size: int = 25) -> Generator[list[Path], None, None]:
    files = sorted(Path(doc_dir).glob("*.docx"))
    for i in range(0, len(files), batch_size):
        yield files[i : i + batch_size]

def convert_batch(batch: list[Path], api_key: str) -> list[tuple[str, bool]]:
    results = []
    with requests.Session() as sess:
        sess.headers.update({"Authorization": f"Bearer {api_key}"})
        for path in batch:
            with open(path, "rb") as f:
                resp = sess.post("https://anymd.net/api/convert", files={"file": (path.name, f)})
            if resp.ok:
                out = path.with_suffix(".md")
                out.write_text(resp.text)
                results.append((path.name, True))
            else:
                results.append((path.name, False))
    return results

# Process 10,000 documents in batches
total, ok = 0, 0
for batch in document_batches("archive", batch_size=25):
    results = convert_batch(batch, "your-api-key")
    for name, success in results:
        total += 1
        if success:
            ok += 1
    print(f"Progress: {ok}/{total}")

print(f"Done: {ok}/{total} documents converted")

Handling large DOCX files

AnyMD handles DOCX files up to 50 MB on the paid plan. For very large files, consider streaming — send the file in chunks and let AnyMD's streaming response send Markdown back as it's generated:

import requests

def convert_large_docx(file_path: str, api_key: str, output_path: str):
    headers = {"Authorization": f"Bearer {api_key}"}
    with open(file_path, "rb") as f:
        with requests.post(
            "https://anymd.net/api/convert?download=1",
            headers=headers,
            files={"file": (file_path, f)},
            stream=True,
        ) as resp:
            resp.raise_for_status()
            with open(output_path, "wb") as out:
                for chunk in resp.iter_content(chunk_size=8192):
                    out.write(chunk)

What gets preserved

DOCX featureMarkdown output
Heading 1–6# through ######
Bold / Italic**bold** / *italic*
Numbered lists1. item — correct nesting
Bullet lists- item — correct nesting
TablesGFM table syntax with alignment
Hyperlinks[text](url)
Images![alt](path)
Code blocksFenced ``` blocks
Blockquotes> quote

Comparison with python-docx

The standard Python library python-docx gives you low-level access to DOCX internals — paragraphs, runs, styles. That's powerful for programmatic document generation, but for extraction it means you're reimplementing every structural rule the Word renderer applies:

  • Headings — must map paragraph styles to Markdown headings manually
  • Lists — requires parsing numPr elements and tracking list depth
  • Tables — row-by-row cell extraction with manual GFM formatting
  • Images — extracting rId references, then unzipping media files
  • Track changes — no built-in support

AnyMD does all of this in one API call. One format to learn, one integration to maintain, one service to monitor.

# python-docx approach — ~150 lines to get passable Markdown
from docx import Document
import re

doc = Document("report.docx")
output = []
for para in doc.paragraphs:
    if para.style.name.startswith("Heading"):
        level = para.style.name[-1]
        output.append(f"{'#' * int(level)} {para.text}")
    else:
        output.append(para.text)

# Compare: AnyMD — 6 lines, no edge cases
# curl https://anymd.net/api/convert -H "Authorization: Bearer ***" -F "file=@report.docx"

From DOCX to RAG pipeline

Once your DOCX files are Markdown, the next step is chunking and embedding for RAG. AnyMD output is the ideal input for semantic chunking strategies — heading boundaries, list structure, and table headers are all preserved, so your chunks respect document semantics instead of cutting through paragraphs mid-sentence.

For a deeper comparison of chunking approaches on Markdown output, see our guide on recursive character chunking.

Pricing

AnyMD converts DOCX to Markdown under the standard page-based pricing:

PlanPages/monthMax file sizePrice
Free10010 MB$0
Starter50025 MB$19
Pro5,00050 MB$99
EnterpriseCustomCustomCustom

A typical DOCX page converts in under 0.3 seconds.

Get your free API key — 100 pages/month, no credit card required.


← Read more →