A lot of company knowledge still lives in Word files: refund policies, onboarding guides, SOPs, proposal templates. If you want that content in a prompt, a RAG index or a chatbot's knowledge base, Markdown is a better format than the .docx itself or a flat text dump. It keeps headings, lists, tables and links, and it is easy to diff, chunk and review.
This post builds a DOCX-to-Markdown converter with Python's standard library only (zipfile and xml.etree.ElementTree), so it runs in a CI job, a small container or a serverless function without a pip install. It handles headings, bold and italic, links, nested bullet and numbered lists, tables, line breaks, images and tracked changes.
Disclosure: Appie.run publishes this post. Appie has a free DOCX to Markdown converter and a few other Word tools, mentioned near the end. The script below does not depend on them.
Earlier posts in this series cover converting CSV and JSON to Markdown tables and converting PDFs to Markdown for RAG.
What is inside a .docx
A .docx file is a ZIP archive of XML files. Rename one to .zip and open it, and you will find:
-
word/document.xml: the body. Paragraphs are<w:p>, runs of text with the same formatting are<w:r>, the text itself is in<w:t>, tables are<w:tbl>. -
word/styles.xml: style definitions. A paragraph only says<w:pStyle w:val="Heading1"/>; this file tells you what that style is. -
word/numbering.xml: list definitions. A list item only says "numbering 3, level 1"; this file tells you whether that level is a bullet or a number. -
word/_rels/document.xml.rels: where hyperlinks point and which file inword/media/each image is.
So a converter has four jobs: read the body in order, look up styles to find headings, look up numbering to tell bullets from numbers, and resolve relationships for links and images.
What a useful converter has to do
-
Find headings by style name, not style ID. IDs such as
Heading1can differ between Word languages and between editors. The name (heading 1) and the outline level are more reliable. -
Merge runs. Word splits text into runs for reasons you cannot see (spell check, edits, language tags). Without merging, you get output like
**annual** **plans**. -
Handle "off" switches.
<w:b w:val="0"/>means not bold. A naive check for the<w:b>tag gets this wrong. - Handle both kinds of lists: numbering set directly on the paragraph, and numbering that comes from a style such as "List Bullet 2".
- Keep tables as tables, with pipes escaped and multi-paragraph cells joined.
- Keep tracked insertions and skip tracked deletions, so you convert the text a reader sees.
-
Not pass through dangerous links. A Word file can contain
javascript:link targets.
The script
Save this as docx2md.py. It was tested with Python 3.13.5 on the files described below.
#!/usr/bin/env python3
"""Convert a .docx file to Markdown with the Python standard library only."""
import posixpath
import re
import sys
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
WP = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}"
PR = "{http://schemas.openxmlformats.org/package/2006/relationships}"
def read_xml(z, name):
try:
return ET.fromstring(z.read(name))
except KeyError:
return None
def is_on(el):
"""<w:b/> means on; <w:b w:val="0"/> or "false" means off."""
return el is not None and el.get(W + "val", "true") not in ("0", "false", "none")
class Docx:
def __init__(self, path, media_dir=None):
self.z = zipfile.ZipFile(path)
self.media_dir = Path(media_dir) if media_dir else None
self.heading_level = self._load_styles()
self.num_formats = self._load_numbering()
self.rels = self._load_rels()
self.counters = {}
def _load_styles(self):
"""Map styleId -> heading level, using the style name or outline level."""
levels, self.style_num, self.style_depth = {}, {}, {}
root = read_xml(self.z, "word/styles.xml")
if root is None:
return levels
for st in root.iter(W + "style"):
sid = st.get(W + "styleId")
name_el = st.find(W + "name")
name = (name_el.get(W + "val") if name_el is not None else "").lower()
m = re.fullmatch(r"heading (\d)", name)
outline = st.find(f"{W}pPr/{W}outlineLvl")
deep = re.fullmatch(r"list (?:bullet|number) (\d)", name) # "List Bullet 2"
if deep:
self.style_depth[sid] = int(deep.group(1)) - 1
num = st.find(f"{W}pPr/{W}numPr") # list styles such as "List Bullet"
if num is not None and num.find(W + "numId") is not None:
ilvl = num.find(W + "ilvl")
self.style_num[sid] = (num.find(W + "numId").get(W + "val"),
ilvl.get(W + "val") if ilvl is not None else "0")
if name == "title":
levels[sid] = 1
elif m:
levels[sid] = int(m.group(1))
elif outline is not None:
levels[sid] = int(outline.get(W + "val")) + 1
return levels
def _load_numbering(self):
"""Map (numId, ilvl) -> numFmt such as 'bullet' or 'decimal'."""
fmts = {}
root = read_xml(self.z, "word/numbering.xml")
if root is None:
return fmts
abstract = {}
for an in root.findall(W + "abstractNum"):
lv = {}
for lvl in an.findall(W + "lvl"):
f = lvl.find(W + "numFmt")
lv[lvl.get(W + "ilvl")] = f.get(W + "val") if f is not None else "bullet"
abstract[an.get(W + "abstractNumId")] = lv
for num in root.findall(W + "num"):
ref = num.find(W + "abstractNumId").get(W + "val")
for ilvl, fmt in abstract.get(ref, {}).items():
fmts[(num.get(W + "numId"), ilvl)] = fmt
return fmts
def _load_rels(self):
rels = {}
root = read_xml(self.z, "word/_rels/document.xml.rels")
if root is not None:
for rel in root.findall(PR + "Relationship"):
rels[rel.get("Id")] = rel.get("Target")
return rels
# ---- inline content -------------------------------------------------
def runs(self, parent, link=None):
"""Yield (text, bold, italic, link) segments for a paragraph."""
for child in parent:
if child.tag == W + "r":
rpr = child.find(W + "rPr")
bold = rpr is not None and is_on(rpr.find(W + "b"))
ital = rpr is not None and is_on(rpr.find(W + "i"))
for el in child:
if el.tag == W + "t":
yield el.text or "", bold, ital, link
elif el.tag == W + "tab":
yield " ", bold, ital, link
elif el.tag in (W + "br", W + "cr"):
yield "\n", False, False, None
elif el.tag == W + "drawing":
yield self.image(el), False, False, None
elif child.tag == W + "hyperlink":
rid = child.get(R + "id")
target = self.rels.get(rid) if rid else None
if target is None and child.get(W + "anchor"):
target = "#" + child.get(W + "anchor")
if target and not re.match(r"(https?:|mailto:|#)", target):
target = None # drop javascript:, file: and other odd targets
yield from self.runs(child, target)
elif child.tag in (W + "ins", W + "smartTag", W + "sdt", W + "sdtContent"):
yield from self.runs(child, link) # tracked insertions, content controls
def image(self, drawing):
blip = drawing.find(f".//{A}blip")
doc_pr = drawing.find(f".//{WP}docPr")
alt = (doc_pr.get("descr") or doc_pr.get("name") or "") if doc_pr is not None else ""
target = self.rels.get(blip.get(R + "embed")) if blip is not None else None
if not target:
return ""
name = posixpath.basename(target)
if self.media_dir:
self.media_dir.mkdir(parents=True, exist_ok=True)
part = posixpath.normpath(posixpath.join("word", target))
(self.media_dir / name).write_bytes(self.z.read(part))
return f"" # relative to the .md file
return f""
def inline(self, p):
# merge neighbouring segments with the same formatting
merged = []
for text, b, i, link in self.runs(p):
if merged and merged[-1][1:] == [b, i, link] and not text.startswith("!["):
merged[-1][0] += text
else:
merged.append([text, b, i, link])
out = []
for text, b, i, link in merged:
core = text.strip()
if not core:
out.append(text)
continue
lead = text[: len(text) - len(text.lstrip())]
trail = text[len(text.rstrip()):]
mark = "**" * b + "*" * i
core = f"{mark}{core}{mark[::-1]}"
if link:
core = f"[{core}]({link})"
out.append(lead + core + trail)
return "".join(out)
# ---- blocks ---------------------------------------------------------
def paragraph(self, p):
self.last_num = None
text = self.inline(p).strip()
ppr = p.find(W + "pPr")
style = ppr.find(W + "pStyle") if ppr is not None else None
sid = style.get(W + "val") if style is not None else None
num = ppr.find(W + "numPr") if ppr is not None else None
if not text:
return None
if sid in self.heading_level:
plain = text.replace("**", "").replace("\n", " ") # headings are bold already
return "#" * min(self.heading_level[sid], 6) + " " + plain
num_id, ilvl = self.style_num.get(sid, (None, "0"))
if num is not None and num.find(W + "numId") is not None:
num_id = num.find(W + "numId").get(W + "val")
ilvl_el = num.find(W + "ilvl")
ilvl = ilvl_el.get(W + "val") if ilvl_el is not None else ilvl
self.last_num = num_id
if num_id and num_id != "0":
fmt = self.num_formats.get((num_id, ilvl), "bullet")
depth = int(ilvl) + self.style_depth.get(sid, 0)
# restart deeper levels when a shallower item appears
for key in [k for k in self.counters if k[0] == num_id and int(k[1]) > depth]:
del self.counters[key]
indent = " " * depth
if fmt == "bullet":
return f"{indent}- {text}"
n = self.counters[(num_id, ilvl)] = self.counters.get((num_id, ilvl), 0) + 1
return f"{indent}{n}. {text}"
if sid and sid.lower() in ("code", "sourcecode", "htmlpreformatted"):
return f"```\n{''.join(t.text or '' for t in p.iter(W + 't'))}\n```"
return text.replace("\n", " \n")
def table(self, tbl):
rows = []
for tr in tbl.findall(W + "tr"):
cells = []
for tc in tr.findall(W + "tc"):
parts = [self.inline(p).strip() for p in tc.iter(W + "p")]
cell = "<br>".join(x for x in parts if x).replace("\n", "<br>")
cells.append(cell.replace("|", "\\|"))
rows.append(cells)
if not rows:
return None
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
lines = ["| " + " | ".join(rows[0]) + " |", "|" + " --- |" * width]
lines += ["| " + " | ".join(r) + " |" for r in rows[1:]]
return "\n".join(lines)
def convert(self):
body = read_xml(self.z, "word/document.xml").find(W + "body")
blocks, prev_list, prev_num, prev_nested = [], False, None, False
for el in body:
if el.tag == W + "p":
md = self.paragraph(el)
elif el.tag == W + "tbl":
md = self.table(el)
elif el.tag == W + "sdt": # content control, e.g. a table of contents
inner = el.find(W + "sdtContent")
paras = inner.findall(W + "p") if inner is not None else []
md = "\n\n".join(filter(None, (self.paragraph(p) for p in paras)))
else:
continue
if not md:
continue
if md.startswith("```\n") and blocks and blocks[-1].endswith("\n```"):
blocks[-1] = blocks[-1][:-3] + md[4:] # join consecutive code lines
continue
is_list = bool(re.match(r"\s*(-|\d+\.) ", md))
same_list = is_list and prev_list and (md[0] == " " or prev_nested or self.last_num == prev_num)
sep = "\n" if same_list else "\n\n"
blocks.append((sep if blocks else "") + md)
prev_list, prev_num = is_list, (self.last_num if is_list else None)
prev_nested = is_list and md[0] == " "
return "".join(blocks).strip() + "\n"
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("usage: docx2md.py input.docx [media_dir] > output.md")
print(Docx(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None).convert(), end="")
Run it:
python3 docx2md.py policy.docx > policy.md # images referenced by file name
python3 docx2md.py policy.docx media > policy.md # also extracts images into ./media
How the pieces fit
-
_load_stylesmaps each style ID to a heading level, using the style name (title,heading 1toheading 6) or an outline level. It also records list styles such as "List Bullet" that carry their own numbering, and treats "List Bullet 2" or "List Number 3" as one or two levels deeper. -
_load_numberingresolves eachnumIdand level to a format. Word stores list formats in two hops (numpoints toabstractNum), which is why there are two loops. -
runswalks a paragraph and yields(text, bold, italic, link)segments. It follows hyperlinks, tracked insertions (w:ins) and content controls. Deleted text lives inw:delText, which it never reads, so tracked deletions drop out. -
inlinemerges neighbouring segments with identical formatting, then puts the**and*markers inside any leading or trailing spaces, because** bold**is not valid Markdown emphasis. -
paragraphdecides between heading, list item, code block and plain paragraph. Numbered items keep a counter per list and level, and deeper counters reset when a shallower item appears. -
convertwalks the body in order, so text and tables stay in their original sequence, and puts list items on consecutive lines so they render as one list.
Try it on a test document
We built a small test file with python-docx (used only to create the fixture; the converter does not need it). It has a Title, Heading 1 and Heading 2, a paragraph with a bold run split in two, an italic run, a bold external link and a run with bold explicitly turned off, a numbered list, a bullet list with a "List Bullet 2" item, a table with a pipe in a cell and a two-paragraph cell, a soft line break, and an image with alt text.
python3 docx2md.py sample.docx media > sample.md
Output:
# Refund policy
Last updated in the Word file. This intro has an empty paragraph after it.
# Who can ask for a refund
Customers on **annual plans** can ask within *30 days*. Read the [**full terms**](https://example.com/terms) first. Not bold
## Steps
1. Open Billing
2. Click Request refund
3. Pick a reason
What you need:
- Order ID
- Account email
- Found in the receipt footer
- Reason
## Plans
| Plan | Window | Notes |
| --- | --- | --- |
| Monthly | 7 days | Pro \| Team |
| Annual | 30 days | Prorated<br>Second line in cell |
Line one
Line two after a soft break

The split bold run came out as one **annual plans**, "Not bold" stayed plain, the nested bullet is indented under its parent, the pipe in "Pro | Team" stayed inside its cell, and the image was written to media/image1.png with its alt text.
We also checked three other inputs:
-
A LibreOffice-made file (HTML converted to DOCX with
soffice --headless), which puts numbering on each paragraph instead of on styles. Nested numbered items came out as1.,2.under1., and a bullet list that followed the numbered list started a new list instead of joining it. -
calibre's public demo DOCX, a Word document built to exercise many features. Headings, the multi-level numbered and bulleted lists, external and internal links, inline images and simple tables came through. Its "continued list" section, where Word resumes numbering at 3 after an interrupting paragraph, also came out as 3 and 4. Its
word/document.xmlis about 138 KB; the Markdown is about 10 KB. -
Edge cases: a tracked change file (the deleted text was dropped and the inserted text kept), a document with a
javascript:link (the link was removed and its text kept, while amailto:link survived), and consecutive paragraphs in a "Code" style (merged into one fenced block).
Convert a folder of Word files
For a shared drive export, convert everything in one pass and keep the folder structure:
from pathlib import Path
from docx2md import Docx
src, dst = Path("docs"), Path("markdown")
for path in sorted(src.rglob("*.docx")):
if path.name.startswith("~$"): # Word lock files
continue
out = dst / path.relative_to(src).with_suffix(".md")
out.parent.mkdir(parents=True, exist_ok=True)
media = out.parent / (out.stem + "_media")
out.write_text(Docx(path, media).convert(), encoding="utf-8")
print(f"{path} -> {out}")
Each Markdown file gets its own <name>_media folder, and image links are written relative to the .md file, so the output can be moved as a unit. The ~$ check skips the lock files Word leaves next to open documents.
Known limits (and how to extend it)
-
Footnotes, endnotes and comments are dropped. They live in
word/footnotes.xml,word/endnotes.xmlandword/comments.xml. To keep footnotes, readw:footnoteReferenceinruns, emit[^n], and append the footnote text at the end. -
Merged and nested table cells are flattened. Markdown tables cannot express
gridSpan,vMergeor a table inside a cell. On calibre's demo, a nested table was folded into a single cell, and a calendar built from merged cells came out with empty columns. Keep such tables as HTML or rebuild them by hand. - Formatting from character styles is ignored. Only direct bold and italic are read. Text that is bold because of a style such as "Strong" comes out plain. Add those style IDs to the bold check if your documents rely on them.
- Text boxes, shapes, headers, footers and drop caps are skipped or come out oddly. A drop cap shows up as a one-letter paragraph.
-
Markdown characters in the text are not escaped. A paragraph that starts with
#or1.will be read as a heading or list by a Markdown renderer. This rarely matters for prompts but matters for published docs. -
Table of contents entries come through as links with page numbers attached (for example
[Tables 3](#_Toc...)). Drop paragraphs whose style name starts with "toc" if you do not want them. -
Old
.docfiles are a different binary format. Convert them to.docxfirst, for example withsoffice --headless --convert-to docx file.doc. -
Untrusted uploads: the script reads files only inside the ZIP and writes images using the base name only, but it does not limit file sizes. If users upload documents, check the uncompressed sizes in
zipfile.ZipFile.infolist()before converting and run it with a timeout.
Why not mammoth or pandoc?
Both are mature and handle more of the edge cases above. mammoth is built to produce clean HTML from Word files; its own documentation says its Markdown output is deprecated and recommends converting to HTML and then to Markdown (checked 2 Nov 2026). If you can install dependencies, that route (mammoth, then an HTML-to-Markdown step) or pandoc -f docx -t gfm is a sound choice. The standard-library script makes sense when you cannot add dependencies, want to read and change every rule, or only need the structure that matters for LLM input.
Is Markdown the right target for chatbot knowledge?
Usually yes. Headings give chunkers natural split points, lists and tables keep their shape, and the file is small and easy to review before it goes live. Two habits help: split on ## headings and keep the source document name with each chunk so answers can cite it, and read the converted file once before you index it. A converter cannot tell you that the refund policy in a 2023 Word file is out of date.
When you do not want to run a script
Appie has free browser tools for Word files. Each run asks for a Cloudflare Turnstile check:
-
Convert DOCX to Markdown: upload a
.docx; the page says heading styles become Markdown headings and Word tables become Markdown tables. For older.docfiles it says to save as.docxfirst. -
AI Chat with Your Word Document: upload a
.docxand ask what it says. The page says answers have to come from the text you provided, and that headings and tables are included. - DOCX to FAQ Generator: writes a Markdown FAQ from a Word file, and the page says it is instructed to skip questions the file cannot answer.
If the end goal is a website chatbot, Appie's AI Chatbots app takes DOCX, PDF, CSV and text uploads as knowledge directly, so you may not need the conversion step at all. If you work from Claude Code or Cursor, Appie's MCP server can add text content to a chatbot (its add_chatbot_content tool takes text, URLs, sitemaps or Content Writer articles, not files), which is where the Markdown from this script fits.
Honest limits of Appie's Word tools
- One file per run, with a fresh captcha each time. There is no batch mode on the tool pages, so a folder of 200 SOPs is a job for the script.
- No options are described for footnotes, comments or merged cells, so test a representative file before relying on the output.
FAQ
Does this work with Google Docs exports?
It should, since Google Docs can download a doc as .docx (File > Download > Microsoft Word (.docx)), but we did not test a Google Docs export for this post. Convert one file and check the headings and lists before running a whole folder.
Why are my headings coming out as plain paragraphs?
The author probably made text big and bold instead of applying a Heading style. The script only sees styles. Apply Heading styles in Word, or add a rule that treats short, fully bold paragraphs as headings.
Can I keep underline, strikethrough or colours?
Markdown has no underline or colour. Strikethrough (~~text~~) is supported by GitHub-flavored Markdown; read w:strike in runs the same way as w:b.
How do I split the output for a RAG index?
Split on lines starting with ##, keep the # title with each chunk, and store the source file path as metadata.
Is it safe to run on files from customers?
Treat them as untrusted input: limit sizes, run with a timeout, and review the Markdown before it reaches a public chatbot. The script drops javascript: and file: links, but that is not a full sanitiser.
If you want a website chatbot trained on your documents, you can try Appie's AI Chatbots on the Free plan (500 credits, no credit card) at appie.run.
RG, Founder, Appie.run
Top comments (0)