What is actually inside a .docx file?
A ZIP of XML parts — 17 of them in the document measured here. The trap is the paragraph model. The sentence "Send the invoice before Friday." is stored as 5 separate w:r runs because one letter is bold, and a naive strip-the-tags extractor reads "The unit price is 100200 USD" from a document that s
A ZIP archive of XML parts — 17 of them in the document measured below — and the reason parsing one is harder than "it's just XML" is that a single sentence is not a single element. Send the invoice before Friday. is 31 visible characters stored as 5 separate w:r runs, because one letter in the middle of "invoice" is bold. Across the whole test document, word/document.xml spends 13.0 bytes of XML per visible character and 26 paragraphs hold 44 runs.
Everything here was run on an Apple M3, 16 GB, macOS 26.4.1: Python 3.14.6 and Node v23.5.0, stdlib and zlib only. No python-docx, no docx4j, no Word and no LibreOffice on this machine — a find across the user account returned 0 existing .docx files, so every file below was built by hand with zipfile.ZipFile. Two independent readers verify they are real documents: macOS textutil -convert txt (the OOXML reader behind TextEdit) round-trips the text, and qlmanage -t renders a page thumbnail showing the header, the table, the image and the footer.
The short answer
- A .docx is an OPC ZIP. The first four bytes are
50 4B 03 04. The minimum valid document is 3 entries and 971 bytes:[Content_Types].xml,_rels/.rels,word/document.xml. A realistic one with styles, fonts, numbering, a header, a footer, footnotes and an image is 17 entries. - One sentence is many runs. A
w:pparagraph containsw:rruns, each with its ownw:rPr. Bolding one letter splits a run into three. The same visible 44-character sentence was stored as 1 run, 4 runs and 43 runs in three files that all extract to byte-identical text. - Deflate does most of the work the format's own tricks try to do. 400 paragraphs at 20 runs each is 564,226 bytes of raw XML compressing to 42,372 — 13.3x. The same text as plain
.txt.gzis 16,375 bytes, so the entire OOXML apparatus costs 2.59x over gzipped text, and only 1.11x when runs are not fragmented. - Strip-the-tags gives you the wrong text, not just ugly text. On the test document it produced
The unit price is 100200 USD— a tracked deletion and its replacement concatenated — leaked aHYPERLINKfield code into the prose, printed the text box twice, and never opened the 3 other parts holding the header, footer and footnote. w:pStyleis a pointer, not a value. Resolving one property in the test document walks 6 levels:IntenseQuote → Quote → BodyTextIndent → BodyText → Normal → docDefaults.
What is actually in the ZIP?
This is full.docx, hand-built to contain the things real documents contain: a heading, body paragraphs, a table, a bullet list, a tracked change, a hyperlink field, a footnote, a text box, a header, a footer and one image.
| Entry | Raw bytes | Zipped | Ratio |
|---|---|---|---|
word/media/image1.png |
1,621,158 | 1,621,653 | 1.00x |
word/document.xml |
6,105 | 1,647 | 3.7x |
word/theme/theme1.xml |
4,391 | 991 | 4.4x |
word/fontTable.xml |
2,590 | 470 | 5.5x |
word/numbering.xml |
2,458 | 499 | 4.9x |
word/styles.xml |
2,259 | 638 | 3.5x |
word/settings.xml |
2,188 | 560 | 3.9x |
[Content_Types].xml |
2,014 | 397 | 5.1x |
word/_rels/document.xml.rels |
1,603 | 319 | 5.0x |
word/footnotes.xml |
1,120 | 420 | 2.7x |
word/footer1.xml |
996 | 371 | 2.7x |
word/endnotes.xml |
922 | 342 | 2.7x |
word/header1.xml |
814 | 337 | 2.4x |
word/webSettings.xml |
765 | 313 | 2.4x |
_rels/.rels |
589 | 230 | 2.6x |
docProps/core.xml |
561 | 318 | 1.8x |
docProps/app.xml |
299 | 208 | 1.4x |
| 17 entries | 1,650,832 | 1,629,713 | 1.0x |
Two things fall out of that table immediately.
The image is 99.5% of the compressed package. The prose you actually care about is 1,647 bytes at the bottom of a 1.6 MB file. If you are building a document pipeline, the memory and bandwidth story is media, and the parsing story is a rounding error on it.
The image also got 495 bytes bigger after deflate — 1,621,158 in, 1,621,653 out. A PNG is already deflated, so running deflate again buys nothing and costs block headers. This is the same effect as gzipping an API response that is already compressed: compression is not free and it is not idempotent.
Strip the media out and the XML side is 29,674 raw bytes compressing to 8,060 — 3.7x. word/document.xml is only 21% of the raw XML. The rest is machinery. settings.xml here carries 40 w:rsid revision-save identifiers, modelled on what Word writes to track editing sessions — 2,188 bytes that nobody reading the document will ever see.
For comparison, macOS textutil -convert docx writes an 8-entry, 3,534-byte package with no styles.xml, no settings.xml and no fontTable.xml at all — those parts are optional. A parser that assumes they exist crashes on files produced by anything other than Word.
Why is the file so much smaller than the XML?
Because OOXML is the most repetitive text a compressor will ever be handed. Every paragraph opens with the same 40 characters. Three hand-built documents, 400 paragraphs each, identical prose, differing only in how finely the runs are split:
| Document | Runs | Raw XML | Zipped | Ratio | Same text as .txt.gz |
Overhead |
|---|---|---|---|---|---|---|
| 1 run per paragraph | 400 | 153,439 | 17,568 | 8.7x | 15,863 | 1.11x |
| 6 runs per paragraph | 2,729 | 270,725 | 25,961 | 10.4x | 15,933 | 1.63x |
| 20 runs per paragraph | 8,495 | 564,226 | 42,372 | 13.3x | 16,375 | 2.59x |
The result I did not expect: the zipped .docx is smaller than the plain text of the same document. 17,568 bytes against 114,416 bytes of .txt — 0.15x. The XML markup is so uniform that deflate's back-references cost almost nothing, and it still gets to compress the English prose underneath.
The second unexpected result is in the last column. Splitting one paragraph into twenty runs inflates the raw XML by 3.7x, but the compressed file only grows by 2.4x. Deflate absorbs most of the damage, because the twentieth <w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve"> is a back-reference to the first. Run fragmentation is a parsing problem, not a storage problem. Which is exactly backwards from how people usually worry about it.
How many XML runs is one sentence?
A w:r run is a span of text sharing one set of properties. Change any property and you need a new run. Here is the paragraph model from word/document.xml in the test file:
1 runs, 25 chars: "Quarterly supplier review"
4 runs, 44 chars: "The quick brown fox jumps over the lazy dog."
5 runs, 31 chars: "Send the invoice before Friday."
4 runs, 35 chars: "The unit price is 200 USD per unit."
7 runs, 47 chars: "See the JSON article for the streaming version."
3 runs, 43 chars: "Total spend was 48,900 USD for the quarter."
The 5-run line is the one to internalise. Send the invoice before Friday. has exactly one bold letter — the o in "invoice" — and that produces Send the , inv, o, ice, before Friday.. This is what happens when somebody types a word and goes back to bold a letter. Real editors also split on spellcheck marks, revision ids and language runs, so the count only ever goes up.
The corollary is that the same visible text has no canonical storage. Three files, all of which textutil converts to the identical 44-character sentence:
| File | Runs | Bytes on disk |
|---|---|---|
minimal.docx |
1 | 971 |
fiveruns.docx |
4 (one bold word) | 1,005 |
perchar.docx |
43 (one per character) | 1,056 |
If your code compares documents, diffs them, or does find-and-replace, run boundaries are invisible to the reader and fatal to you. invoice is not a string in that file. It is inv + o + ice in three sibling elements, and a regex looking for invoice finds nothing.
What does strip-the-tags actually lose?
The one-line extractor everybody writes first is re.sub(r'<[^>]+>', '', xml). Run against the test document it returns 532 characters; a namespace-aware walk returns 471. The extra 61 characters are all wrong, in five distinct ways.
The unit price is 100200 USD per unit.
See HYPERLINK "https://cstsolution.com/blog/parsing-huge-json/" the JSON article
...Deadline: 30 SeptemberDeadline: 30 September...
...Quarterly supplier reviewThe quick brown fox...
w:delTextfrom a tracked change. The document says200. Strip-tags says100200, because the deleted100is still in the file inside aw:del. A number that is wrong and looks right is the worst failure mode there is.w:instrTextfield codes.HYPERLINK "https://…"is an instruction to the renderer, not prose. Strip-tags pastes the URL into the sentence.mc:AlternateContentduplicates. A text box is written twice — once inmc:Choice, once inmc:Fallback— so "Deadline: 30 September" appears twice.textutilitself emits it twice, so this is not a beginner's mistake.- Paragraph boundaries vanish.
reviewThe,Alpha Ltd18,200.</w:p>is a tag, and stripping tags deletes the only line break in the file. - Three whole parts never get opened.
word/header1.xml,word/footer1.xmlandword/footnotes.xmlholdCST Solution internal draft,Page 1andThe figure excludes VAT.respectively. None of that text is inword/document.xml.
There is a sixth, quieter one. 9 of the 36 w:t elements carry xml:space="preserve", and their whitespace is load-bearing. An extractor that calls .strip() on each element — a very natural thing to write — turns The quick brown fox jumps over the lazy dog. into The quick brown fox jumps over thelazydog.. Nine characters, silently deleted, and every downstream token boundary is wrong. If that text is going into a search index, see normalising text for search: you cannot normalise your way out of missing spaces.
How deep does a style reference go?
<w:pPr><w:pStyle w:val="IntenseQuote"/></w:pPr> does not say what the paragraph looks like. It names a style in styles.xml, which names another via w:basedOn, and so on down to the docDefaults block. Walking the 7 styles in the test document:
depth 2 Normal -> docDefaults
depth 3 BodyText -> Normal -> docDefaults
depth 4 BodyTextIndent -> BodyText -> Normal -> docDefaults
depth 5 Quote -> BodyTextIndent -> BodyText -> Normal -> docDefaults
depth 6 IntenseQuote -> Quote -> BodyTextIndent -> BodyText -> Normal -> docDefaults
Six levels to answer "is this paragraph italic?", and direct w:rPr on the run overrides all of them. Any tool that wants to preserve formatting — a converter, an editor that opens a file and hands it back — has to resolve that chain, keep the unresolved chain intact, or it will quietly flatten a document's styling on save.
Is .xlsx the same idea?
Same container, one important difference: a worksheet does not store repeated strings inline. It stores an index into xl/sharedStrings.xml. Two hand-built workbooks, 5,000 rows x 4 columns, 15,000 string references drawn from 11 unique values:
| Encoding | sheet1.xml raw |
Zipped | sharedStrings.xml |
Package on disk |
|---|---|---|---|---|
| Inline strings | 1,132,394 | 105,645 | — | 107,413 |
| Shared strings | 705,781 | 95,716 | 472 raw / 265 zipped | 97,894 |
Shared strings cut the raw XML by 1.60x — and the file on disk by only 1.10x. Deflate had already found the 15,000 repetitions. The table earns its keep in memory and parse time, not bytes: a streaming reader loads 11 strings once instead of materialising 15,000. It is also the classic .xlsx bug — a cell with t="s" holds <v>7</v>, and a parser that ignores the t attribute reads the number seven where the sheet shows Rejected. Same class of silent corruption as a CSV read in the wrong encoding: it parses cleanly and it is wrong.
Check it yourself
One file, Node built-ins only, no dependencies and no network. It reads the ZIP central directory by hand, prints the entry table, counts runs per paragraph, and reports exactly what strip-the-tags would swallow. Run it on any .docx.
node inside-docx.mjs some-document.docx
// inside-docx.mjs — what is actually inside a .docx. Node 23, built-ins only.
import fs from 'node:fs';
import zlib from 'node:zlib';
const file = process.argv[2];
if (!file) { console.error('usage: node inside-docx.mjs <file.docx>'); process.exit(1); }
const buf = fs.readFileSync(file);
// --- read the ZIP central directory by hand: a .docx is an OPC ZIP ---
let eocd = buf.length - 22;
while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('not a ZIP: no end-of-central-directory record');
const count = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16);
const entries = [];
for (let i = 0; i < count; i++) {
if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error('bad central directory at ' + off);
const method = buf.readUInt16LE(off + 10);
const csize = buf.readUInt32LE(off + 20), usize = buf.readUInt32LE(off + 24);
const nlen = buf.readUInt16LE(off + 28), elen = buf.readUInt16LE(off + 30);
const clen = buf.readUInt16LE(off + 32), lho = buf.readUInt32LE(off + 42);
entries.push({ name: buf.toString('utf8', off + 46, off + 46 + nlen), method, csize, usize, lho });
off += 46 + nlen + elen + clen;
}
const read = (e) => {
const n = buf.readUInt16LE(e.lho + 26), x = buf.readUInt16LE(e.lho + 28);
const raw = buf.subarray(e.lho + 30 + n + x, e.lho + 30 + n + x + e.csize);
return e.method === 0 ? raw : zlib.inflateRawSync(raw);
};
// --- 1. the entry table ---
console.log(`${file} ${buf.length} bytes on disk, ${entries.length} ZIP entries\n`);
console.log('entry'.padEnd(34) + 'raw'.padStart(10) + 'zipped'.padStart(10) + 'ratio'.padStart(8));
let tr = 0, tz = 0;
for (const e of entries.sort((a, b) => b.usize - a.usize)) {
tr += e.usize; tz += e.csize;
const r = e.csize ? (e.usize / e.csize).toFixed(1) + 'x' : '-';
console.log(e.name.padEnd(34) + String(e.usize).padStart(10) + String(e.csize).padStart(10) + r.padStart(8));
}
console.log('TOTAL'.padEnd(34) + String(tr).padStart(10) + String(tz).padStart(10) +
((tr / tz).toFixed(1) + 'x').padStart(8));
// --- 2. the paragraph model ---
const main = entries.find((e) => e.name === 'word/document.xml');
if (!main) { console.log('\nno word/document.xml — is this really a .docx?'); process.exit(0); }
const xml = read(main).toString('utf8');
const paras = xml.split('</w:p>').slice(0, -1);
const text = [...xml.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join('');
console.log(`\nword/document.xml: ${xml.length} chars of XML`);
console.log(` w:p ${paras.length} w:r ${(xml.match(/<w:r[ >]/g) || []).length}` +
` w:t ${(xml.match(/<w:t[ >]/g) || []).length} visible chars ${text.length}`);
console.log(` ${(xml.length / Math.max(text.length, 1)).toFixed(1)} XML bytes per visible character`);
console.log('\nruns per paragraph (first 8 non-empty):');
let shown = 0;
for (const p of paras) {
const t = [...p.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join('');
if (!t.trim() || shown++ >= 8) continue;
console.log(` ${String((p.match(/<w:r[ >]/g) || []).length).padStart(3)} runs, ` +
`${String(t.length).padStart(4)} chars: ${JSON.stringify(t.slice(0, 60))}`);
}
// --- 3. what strip-the-tags loses ---
const naive = xml.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
const instr = [...xml.matchAll(/<w:instrText[^>]*>([^<]*)<\/w:instrText>/g)].map((m) => m[1]);
const del = [...xml.matchAll(/<w:delText[^>]*>([^<]*)<\/w:delText>/g)].map((m) => m[1]);
console.log(`\nstrip-the-tags gives ${naive.length} chars; it also swallows:`);
console.log(` ${instr.length} w:instrText field codes ${JSON.stringify(instr.slice(0, 2))}`);
console.log(` ${del.length} w:delText deleted runs ${JSON.stringify(del.slice(0, 3))}`);
console.log(` ${(xml.match(/<mc:Fallback>/g) || []).length} mc:Fallback blocks (text box counted twice)`);
const other = entries.filter((e) => /word\/(header|footer|footnotes|endnotes|comments)/.test(e.name));
console.log(` and ${other.length} part(s) it never opens at all:`);
for (const e of other) {
const t = [...read(e).toString('utf8').matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((m) => m[1]).join('').trim();
if (t) console.log(` ${e.name.padEnd(22)} ${JSON.stringify(t.slice(0, 60))}`);
}
// --- 4. how deep does a style chain go ---
const st = entries.find((e) => e.name === 'word/styles.xml');
if (st) {
const sx = read(st).toString('utf8');
const based = new Map();
for (const m of sx.matchAll(/<w:style [^>]*w:styleId="([^"]+)"[\s\S]*?<\/w:style>/g)) {
const b = /<w:basedOn w:val="([^"]+)"/.exec(m[0]);
based.set(m[1], b ? b[1] : null);
}
console.log(`\nword/styles.xml defines ${based.size} styles. Resolution chains:`);
for (const id of based.keys()) {
const chain = [id];
let cur = based.get(id), guard = 0;
while (cur && guard++ < 20) { chain.push(cur); cur = based.get(cur); }
console.log(` depth ${chain.length + 1} ${chain.join(' -> ')} -> docDefaults`);
}
}
If you have no .docx to hand, this Python builds a valid one from nothing — three entries, 1,004 bytes, three runs, and it opens:
import zipfile
X = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
P = 'http://schemas.openxmlformats.org/package/2006'
O = 'http://schemas.openxmlformats.org/officeDocument/2006'
with zipfile.ZipFile('minimal.docx', 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('[Content_Types].xml', X + f'<Types xmlns="{P}/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-'
'officedocument.wordprocessingml.document.main+xml"/></Types>')
z.writestr('_rels/.rels', X + f'<Relationships xmlns="{P}/relationships">'
f'<Relationship Id="rId1" Type="{O}/relationships/officeDocument" '
'Target="word/document.xml"/></Relationships>')
z.writestr('word/document.xml', X + f'<w:document {W}><w:body>'
'<w:p><w:r><w:t xml:space="preserve">The quick brown fox jumps over the </w:t></w:r>'
'<w:r><w:rPr><w:b/></w:rPr><w:t>lazy</w:t></w:r>'
'<w:r><w:t xml:space="preserve"> dog.</w:t></w:r></w:p>'
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:body></w:document>')
On macOS you can verify it without Word: textutil -convert txt -stdout minimal.docx prints the sentence, and qlmanage -t -s 200 -o . minimal.docx renders a page thumbnail. Both were used to check every file in this article.
Total footprint of the whole lab: 1.9 MB.