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 feature | Markdown output |
|---|---|
| Heading 1–6 | # through ###### |
| Bold / Italic | **bold** / *italic* |
| Numbered lists | 1. item — correct nesting |
| Bullet lists | - item — correct nesting |
| Tables | GFM table syntax with alignment |
| Hyperlinks | [text](url) |
| Images |  |
| Code blocks | Fenced ``` 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
numPrelements and tracking list depth - Tables — row-by-row cell extraction with manual GFM formatting
- Images — extracting
rIdreferences, 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:
| Plan | Pages/month | Max file size | Price |
|---|---|---|---|
| Free | 100 | 10 MB | $0 |
| Starter | 500 | 25 MB | $19 |
| Pro | 5,000 | 50 MB | $99 |
| Enterprise | Custom | Custom | Custom |
A typical DOCX page converts in under 0.3 seconds.
Get your free API key — 100 pages/month, no credit card required.