Reading Office Open XML From Docx and Xlsx Files in Python
A docx or xlsx is a zip of XML files. Open it with zipfile, read the paragraphs, runs and tables of a docx and the shared strings and cells of an xlsx.

The modern Office formats look like binary files and are not. A .docx or .xlsx is a zip archive, and inside it are XML files in the Office Open XML vocabulary. That means you can read the text of a Word document or the cells of a workbook with a zip library and an XML parser, with no Office installation and no Windows. It is how many converters and indexers work, and it is a useful trick when a library is missing or fails.
A docx is a zip
unzip -l report.docx[Content_Types].xml
_rels/.rels
docProps/core.xml
docProps/app.xml
word/document.xml
word/_rels/document.xml.rels
word/styles.xml
word/settings.xml
word/fontTable.xml
word/theme/theme1.xml
word/numbering.xml
...(Trimmed to the first eleven parts.) The text of the document is in word/document.xml. The other parts hold styles, fonts, numbering definitions, settings, theme colors and metadata (author and dates in docProps). The [Content_Types].xml file says what each part is, and the .rels files wire the parts together, for example linking an image to the paragraph that shows it.
The namespace of the document part is http://schemas.openxmlformats.org/wordprocessingml/2006/main, usually bound to the prefix w. The root declares a dozen other namespaces for legacy and extension features, and you can ignore all but w.
Paragraphs, runs and text
import zipfile
from lxml import etree
W = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
z = zipfile.ZipFile("report.docx")
body = etree.fromstring(z.read("word/document.xml")).find("w:body", W)
for p in body.iterfind("w:p", W):
runs = [("".join(r.xpath("w:t/text()", namespaces=W)), r.find("w:rPr/w:b", W) is not None)
for r in p.iterfind("w:r", W)]
print("para:", runs, "| style:", p.xpath("string(w:pPr/w:pStyle/@w:val)", namespaces=W))para: [('Quarterly report', False)] | style: Heading1
para: [('Revenue grew ', False), ('12 percent', True), (' in Q3 & costs fell.', False)] | style: Three nesting levels carry the text. A paragraph w:p is a block. Inside it, a run w:r is a stretch of text with one set of formatting, and the text itself is in w:t. The sentence "Revenue grew 12 percent in Q3 & costs fell." is three runs, because the middle one is bold: its run properties w:rPr contain an empty w:b element. The & was decoded by the parser. The first paragraph has a style name, Heading1, in w:pPr/w:pStyle, which is how you find headings.
Run boundaries are arbitrary. Word splits runs for spell-check marks, revision history and edits, so a single word can be in two runs, and a naive search for a phrase in individual w:t elements will miss it. Join the runs of a paragraph before you search. Also note that xml:space="preserve" on w:t is there to keep leading and trailing spaces ("Revenue grew " has a trailing space), and an XML pretty-printer must not remove it.
Tables follow the same pattern:
print("table:", [["".join(c.xpath(".//w:t/text()", namespaces=W)) for c in tr.iterfind("w:tc", W)]
for tr in body.iterfind("w:tbl/w:tr", W)])table: [['Region', 'Sales'], ['North', '1,200']]A table w:tbl has rows w:tr, which have cells w:tc, which contain paragraphs. The cell text is "1,200" as a string, formatted by the author; there are no types in Word.
An xlsx is a zip of sheets
A workbook has xl/workbook.xml (the list of sheets), xl/worksheets/sheet1.xml and so on (the cells), xl/styles.xml (number formats and fonts) and, in most files, xl/sharedStrings.xml (the text of every string cell, stored once). The sheet namespace is http://schemas.openxmlformats.org/spreadsheetml/2006/main.
The cell element c has an address r and a type t, and the value in a child v:
M = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
ss = ["".join(si.xpath(".//m:t/text()", namespaces=M)) for si in etree.fromstring(z.read("xl/sharedStrings.xml")).iterfind("m:si", M)]
sheet = etree.fromstring(z.read("xl/worksheets/sheet1.xml"))
for c in sheet.iterfind(".//m:c", M):
t = c.get("t"); v = c.findtext("m:v", namespaces=M); f = c.findtext("m:f", namespaces=M)
val = ss[int(v)] if t == "s" else (v == "1" if t == "b" else float(v))
print(c.get("r"), "t=%s" % t, repr(val), "formula=%s" % f if f else "")A1 t=s 'sku'
A2 t=s 'A-100'
B2 t=None 2.0
C2 t=None 46000.5
D2 t=b True
B3 t=None 0.3
D3 t=b False
B4 t=None 2.3 formula=B2+B3(Trimmed to the interesting cells.) The rules show up directly. A cell with t="s" holds a number in v that indexes the shared strings list, so A2 is entry number 4, 'A-100'. A boolean has t="b" and 1 or 0. A number has no t at all. A date is a number too (46000.5 is a serial date with a time of noon) and only the cell's style says it should be shown as a date, so you cannot tell a date from a number without reading styles.xml. The formula cell B4 has both f (the formula text, B2+B3) and v (the value Excel last calculated, 2.3). The cached value can be stale if the file was written by a tool that did not recalculate.
One surprise: the value 0.1 + 0.2 I wrote came back as 0.3 here, because xlsxwriter writes numbers with a limited number of digits. And a file written by openpyxl has no sharedStrings.xml at all when it writes strings inline (t="inlineStr" with an is/t child). A reader written against one library's output fails on the other's, so handle s, inlineStr, str (a formula result that is text), b, e (error) and numeric.
When to use a library
Reading a small thing this way is easy. Reading everything properly is not. Merged cells, styles that decide whether a number is a date, shared formulas, rich text in cells, comments, hidden rows, tracked changes in Word, fields, footnotes, headers and footers each add rules. Use python-docx or openpyxl (or pandas.read_excel) for ordinary work. Go to the XML when you need something the library hides: the exact formula text, the revision marks, the comments, a part the library does not model, or when you are scanning thousands of files and the library is slow.
Security matters here too. An Office file from outside can contain macros (in .docm and .xlsm, as a binary vbaProject.bin part), external links and embedded objects. Reading the XML does not run any of them. Opening the file in Office might. Parse with entity expansion disabled, and be wary of zip bombs: check the declared uncompressed size of each part before you read it.
Related reading
To write workbooks instead, see converting XML to Excel with Python, and for the spreadsheet side of XML, moving XML into and out of spreadsheets. The overview of formats is common XML formats you will meet in real work. The standard is ECMA-376 (also ISO/IEC 29500), and the libraries are documented at python-docx and openpyxl. Try a path in the XPath tester.
When a library should read the file
The task is ordinary. Extract text, read a table, load a sheet into a DataFrame: the libraries handle the details above.
You need dates and formats right. The style information that decides whether a number is a date is easy to get wrong by hand.
The file is old. Binary .doc and .xls files are not zip files and have no XML inside. They need a different tool.
The file may be hostile. Use a library with limits, and open it in a sandbox. Reading raw XML out of an archive of unknown size needs your own guards.


