xlsx Surgery
When to use this
Any time you need to modify an existing .xlsx under the requirement "change only the cells or
regions I named — leave everything else byte-for-byte identical." Especially when the original
contains formulas, fine-grained styles, charts, pivot tables, or external links: the things a
library round-trip quietly damages. An xlsx is a zip wrapping a pile of XML parts; a naive
"load → edit → save" will silently destroy parts you never touched. This skill is the map of
those landmines and how to walk around them.
★Trigger discipline (when you MUST check your work against this skill)
The trigger is not "when you sit down to write xlsx-manipulation code" — it is "any moment any
code path touches an xlsx deliverable." That includes wiring up an existing module, not just
new code. A real incident: Rules 1/3/7 were written down in black and white, yet simply connecting
"produce the deliverable" to an existing function (which internally did load_workbook +
wb.save, a full-workbook rewrite) destroyed the charts anyway — because nobody walked that
existing path through the rules at the moment of wiring it up.
Two hard rules — write them into the plan, run them before delivery:
- Check before you wire: any code path that writes / converts / moves / saves-as an xlsx
deliverable (including calls into existing modules) gets walked through Rules 1–7 before
you connect it, and "this path uses ZIP surgery vs. a full rewrite" goes into the plan.
No matter how harmless the existing function's name looks (
append_source / build / save_as…),
open it and read how it actually writes the file.
- The exit self-check is mandatory: before delivery, run Rule 3's exit self-check — open the
file in real Excel (or diff it against the original at the byte / chart-parts level) and confirm
"it opens + the charts are there + the structure is equivalent." A green from a permissive tool
(openpyxl/LO) does not count as passing.
Core physics
Rule 1: Minimal incision — change the target only, never rewrite the whole file or sheet
Mainstream libraries (openpyxl, for instance) "load the whole workbook and save it back," which
re-serializes every part. Anything the library does not understand (certain charts, pivot tables,
external links, uncommon namespaces) can be dropped, downgraded, or rewritten. The surgical way:
- Do precise string-level replacement on the target part only (one worksheet XML); move every
other part across untouched (ZIP-level copy).
- Swapping one sheet means swapping that one part's bytes — do not repack the entire workbook.
- Never parse a whole sheet's root element (see the namespace landmine in Rule 2).
Rule 2: Five file-level landmines that real Excel rejects and permissive tools cannot see
Every one of these is tolerated by an openpyxl / LibreOffice round-trip (it renders fine — a false
green), and only surfaces when real Microsoft Excel opens the file. Typical symptom: "the file
is corrupt and must be repaired," part XML error 0x808c0002 at "row 1, column 0."
Whole-tree parsing destroys namespaces: parse a whole worksheet with an XML tree library
(ElementTree, say) and write it back out → it renames namespace prefixes (mc: → ns1:) and
deletes namespace declarations it "believes are unused" as if they were garbage — except that
declaration was referenced inside an attribute's value (mc:Ignorable="x14ac", where x14ac
is a prefix living in the attribute value; a tree library cannot read attribute values, so it
deletes the declaration → the reference now points at an undefined prefix → the root element is
broken). Avoidance: never parse the root element; do string-level edits on the target element only.
Missing XML declaration: the first line of every XML part must be <?xml version="1.0" …?>.
Some serialization paths (ElementTree.tostring(encoding="unicode"), for one) emit no
declaration → Excel judges the part corrupt. Avoidance: before writing back any .xml/.rels
part, run a final check and add the declaration if it is missing.
A formula cell with no cached value (<f> without <v>): you wrote the formula node <f>
but no value node <v> → before it recalculates, real Excel shows that cell as blank or 0
(it trusts the cache); permissive tools evaluate on the fly, so they hide the problem.
Avoidance: when you write a formula, backfill the <v> cache alongside it (with a value from
an independent source — see verification-discipline), or explicitly flag the file fullCalcOnLoad.
Broken defined names (definedName → #REF!): after deleting rows, columns, or sources, the
workbook's <definedName> entries may point at ranges that no longer exist → they carry #REF! →
Excel complains. Avoidance: strip dead <definedName> entries at the string level (do not parse
the workbook root element); if all of them go, remove the <definedNames> container too.
Formulas flattened into dead values: load in a values-only mode (data_only=True, for
instance) and save → live formulas become the dead values they last evaluated to, and they
will never update when the source changes. The value looks right today: this is the most insidious
false green of all. Avoidance: when touching a file with formulas, always preserve the formula
expressions; never read values out with data_only and write them back as content.
Rule 3: A permissive tool's "green" is a false green — real Excel is the acceptance gate
What the five landmines have in common: a clean openpyxl round-trip, and LibreOffice opening the
file, prove nothing about whether real Excel can open it. Those tools are highly tolerant and will
render a corrupt file as normal. Final acceptance for an xlsx artifact must be a real Microsoft
Excel actually opening it (or a byte-level comparison against a known-good original); never take a
permissive tool's success as a pass. (This echoes verification-discipline: verify in the user's real
environment, not through a lenient proxy.)
Rule 4: Inherit the whole cell — write the style with the value at the source, leave no "patch the styles later" step
When you want to "copy a template cell's complete appearance (borders / fill / number format / font)
and only swap the value inside," write the style index and the value into the new cell in one step
(<c r=.. s=template's style index>value</c>). Do not "write values first and patch styles
afterwards" — that second pass will eventually miss cells (especially blank ones that still carry
borders or fill). Blank cells inherit too (a bordered empty cell still gets written as
<c r=.. s=S/>; do not skip it). Nail it in one pass — there is no second pass to forget.
Rule 5: Inherit number formats per column, never blanket one format across the sheet
Different columns in one table usually carry different number formats (thousands separators here,
percentages there, dates in a third). When filling values, each column inherits the number format of
its corresponding template column; do not apply one format to the whole sheet. A broken format does
not change "the value is right" but it does change "this is correct" (see verification's
multi-dimensional comparison).
Rule 6: Structural insertion means "insert + shift," never "overwrite"
When adding a column, a row, or a period (a monthly report gaining one more month column, a chart
gaining one more data point), you insert and push everything after it along — you do not
overwrite some existing position. Overwriting quietly eats whatever was there. Inserting a column →
every column at or after the target shifts by +1; a chart's sliding window → the new data point is
added and the window shifts, rather than clobbering the oldest entry.
Rule 7: Move live objects (pivots/charts) verbatim + "refresh on load"; never rebuild them with a library
Live objects like pivot tables and charts are almost guaranteed to come out distorted — or vanish —
when a library rebuilds them. Instead:
- Verbatim ZIP-copy: move the pivot/chart parts across untouched (caches included) and change only
the source data they point at.
- Set the refresh-on-open flag (
refreshOnLoad for pivots) so Excel recomputes them from the new
data when the file opens — you never compute their contents by hand.
- As a bonus this is faster than the library route (no cost of rebuilding large objects).
Rule 8: The physics of reading big files — stream, never materialize a whole sheet
Rules 1–7 cover "how to write without breaking things"; this one covers "how to read without blowing
up." An xlsx's XML body is often far larger than the file itself (compression is very effective), and
a mainstream library's normal load consumes roughly 50 times the file size in memory (the official
documentation says so outright: a 50MB file → 2.5GB of memory). For a worksheet in the hundreds of
megabytes, loading the whole sheet or building a whole-sheet dictionary of cell objects is
guaranteed to exhaust memory. That is a documented property of the library, not your bug.
- Always stream a large sheet: read-only lazy loading + row-by-row iteration (iter_rows), which
holds memory nearly constant; never materialize the whole sheet (no whole-sheet cell dict, no
"read it all into a list first, then process").
- Need repeated lookups → go to disk, not to memory: stream it into an embedded database (SQLite,
say — a single file, zero dependencies) and then point-query by cell (microseconds, near-zero
memory). Never pickle the whole dictionary as your persistence layer — loading it back rebuilds
the whole thing and the memory peak reappears exactly as before, which makes the work pointless.
- Peak memory must be pinned by an assertion: peak RSS on the big-file path goes into the
regression baseline (see engineering-economy Rule 12). "Constant memory" is a guarantee held down by
a breaking test, not a claim.
Case files from this project (supporting evidence, not required for the general rules)
- The core module, a string-level XML surgery module: one financial-reporting automation project
turned all of the above into a "minimal string-level" editing module. Its docstring says so from the
first line: "change only the target
<c>'s string; the <worksheet>/<workbook> root elements,
every namespace declaration, and every other cell are left untouched — never parse a whole sheet's
root element."
- All five landmines were struck for real, by the owner, during acceptance in real Excel:
- Rule 2-①: ET parsing a whole sheet turned
mc into ns1 and deleted the xmlns:x14ac that was
referenced from the mc:Ignorable="x14ac" attribute value → <worksheet> corrupt at row 2
column 0, 0x808c0002 (the namespace-corruption detector is the dedicated check for exactly this
landmine: does every prefix referenced by Ignorable have a declaration, and are there leftover
ns0/ns1 rewrites?).
- Rule 2-②:
ET.tostring(encoding="unicode") emits no declaration → Excel reports a part XML error;
the declaration-completion function at the write boundary adds <?xml … standalone="yes"?> as the
zip is written.
- Rule 2-③: the formula-cache backfill function exists purely to fill in cached values for cells
that have
<f> but no <v>.
- Rule 2-④: the dead-name filter strips invalid
<definedName> entries at the string level, and
removes the entire <definedNames> container when they all go.
- Rule 3: for that same corrupt file, "LO/openpyxl tolerate it = false green" is written verbatim in
the comments of the declaration-completion function — only real Excel catches it.
- Rules 4/5: the whole-cell inheritance writer writes a cell in one step (
s= takes the entire
style index from the corresponding cell of the April template, overwriting only value/formula;
blanks inherit as whole cells too), under the iron rule "there is no separate 'patch the styles'
step to miss"; the cell-by-cell comparator's column-anchor pairing aligns columns and compares them
one by one (number formats included).
- Rule 6: the column-shift function (after inserting, every col ≥ at_col shifts by
+count), the
in-place region replacement function (swap a rectangular region while preserving column order), and
"add + shift" for a new month column; the chart's sliding window works the same way.
- Rule 7: the three pivot-table deliverables use verbatim ZIP-copy +
refreshOnLoad (faster than
an openpyxl rebuild and lossless); the audit explicitly records "pivot recomputes live via
refreshOnLoad."
- Rule 8: one 296MB (uncompressed) dependency sheet — building a whole-sheet dictionary of a
million cell values was OOM-killed twice in a row; after switching to "read_only + iter_rows
streaming → batch writes into SQLite → cross-references via point lookups," the same sheet seeded in
158.5 seconds with zero OOM and constant memory. Peak RSS was then locked in as a baseline assertion
plus a breaking test ("deliberately materialize the whole sheet → it must bark"). The 50x memory law
only came to light afterwards, by reading the official documentation: everyone hits this. Checking
external experience first (engineering-economy Rule 1) would have saved both OOMs.
1---2name: xlsx-surgery3description: The physics of surgically editing an existing Excel/xlsx (OOXML) file. Use when you must change a few cells or structures in a workbook that already exists while leaving everything else byte-identical — formulas, styles, charts, pivot tables, external links. Covers why library round-trips silently corrupt parts you never touched, how to make minimal string/ZIP-level incisions, the five file-level landmines that real Excel refuses to open but permissive tools show as fine, and the streaming rules for reading hundred-megabyte worksheets without exhausting memory (the 50x memory law, on-disk point lookups, never materialize a whole sheet).4---56# xlsx Surgery78## When to use this910Any time you need to **modify an existing .xlsx** under the requirement "change only the cells or11regions I named — **leave everything else byte-for-byte identical**." Especially when the original12contains **formulas, fine-grained styles, charts, pivot tables, or external links**: the things a13library round-trip quietly damages. An xlsx is a zip wrapping a pile of XML parts; a naive14"load → edit → save" will **silently destroy parts you never touched**. This skill is the map of15those landmines and how to walk around them.1617### ★Trigger discipline (when you MUST check your work against this skill)1819**The trigger is not "when you sit down to write xlsx-manipulation code" — it is "any moment any20code path touches an xlsx deliverable."** That includes **wiring up an existing module**, not just21new code. A real incident: Rules 1/3/7 were written down in black and white, yet simply connecting22"produce the deliverable" to an **existing** function (which internally did `load_workbook` +23`wb.save`, a full-workbook rewrite) destroyed the charts anyway — because nobody walked that24existing path through the rules **at the moment of wiring it up**.2526**Two hard rules — write them into the plan, run them before delivery:**271. **Check before you wire**: any code path that **writes / converts / moves / saves-as** an xlsx28 deliverable (**including calls into existing modules**) gets walked through **Rules 1–7** before29 you connect it, and "this path uses ZIP surgery vs. a full rewrite" goes into the plan.30 No matter how harmless the existing function's name looks (`append_source` / `build` / `save_as`…),31 open it and read **how it actually writes the file**.322. **The exit self-check is mandatory**: before delivery, run **Rule 3's exit self-check** — open the33 file in real Excel (or diff it against the original at the byte / chart-parts level) and confirm34 "it opens + the charts are there + the structure is equivalent." A green from a permissive tool35 (openpyxl/LO) does **not** count as passing.3637---3839## Core physics4041### Rule 1: Minimal incision — change the target only, never rewrite the whole file or sheet4243Mainstream libraries (openpyxl, for instance) "load the whole workbook and save it back," which44**re-serializes every part**. Anything the library does not understand (certain charts, pivot tables,45external links, uncommon namespaces) can be dropped, downgraded, or rewritten. **The surgical way**:46- Do **precise string-level replacement** on the **target part only** (one worksheet XML); move every47 other part across **untouched** (ZIP-level copy).48- Swapping one sheet means swapping that one part's bytes — do not repack the entire workbook.49- **Never parse a whole sheet's root element** (see the namespace landmine in Rule 2).5051### Rule 2: Five file-level landmines that real Excel rejects and permissive tools cannot see5253Every one of these is **tolerated by an openpyxl / LibreOffice round-trip (it renders fine — a false54green)**, and only surfaces when **real Microsoft Excel opens the file**. Typical symptom: "the file55is corrupt and must be repaired," part XML error `0x808c0002` at "row 1, column 0."56571. **Whole-tree parsing destroys namespaces**: parse a whole worksheet with an XML tree library58 (ElementTree, say) and write it back out → it **renames** namespace prefixes (`mc:` → `ns1:`) and59 **deletes** namespace declarations it "believes are unused" as if they were garbage — except that60 declaration was referenced **inside an attribute's *value*** (`mc:Ignorable="x14ac"`, where `x14ac`61 is a prefix living in the attribute value; a tree library cannot read attribute values, so it62 deletes the declaration → the reference now points at an undefined prefix → the root element is63 broken). **Avoidance: never parse the root element; do string-level edits on the target element only.**64652. **Missing XML declaration**: the first line of every XML part must be `<?xml version="1.0" …?>`.66 Some serialization paths (`ElementTree.tostring(encoding="unicode")`, for one) **emit no67 declaration** → Excel judges the part corrupt. **Avoidance: before writing back any `.xml`/`.rels`68 part, run a final check and add the declaration if it is missing.**69703. **A formula cell with no cached value (`<f>` without `<v>`)**: you wrote the formula node `<f>`71 but no value node `<v>` → before it recalculates, real Excel shows that cell as **blank or 0**72 (it trusts the cache); permissive tools evaluate on the fly, so they hide the problem.73 **Avoidance: when you write a formula, backfill the `<v>` cache alongside it** (with a value from74 an independent source — see verification-discipline), or explicitly flag the file `fullCalcOnLoad`.75764. **Broken defined names (`definedName` → `#REF!`)**: after deleting rows, columns, or sources, the77 workbook's `<definedName>` entries may point at ranges that no longer exist → they carry `#REF!` →78 Excel complains. **Avoidance: strip dead `<definedName>` entries at the string level (do not parse79 the workbook root element); if all of them go, remove the `<definedNames>` container too.**80815. **Formulas flattened into dead values**: load in a values-only mode (`data_only=True`, for82 instance) and save → **live formulas become the dead values they last evaluated to**, and they83 will never update when the source changes. The value looks right today: this is the most insidious84 false green of all. **Avoidance: when touching a file with formulas, always preserve the formula85 expressions; never read values out with data_only and write them back as content.**8687### Rule 3: A permissive tool's "green" is a false green — real Excel is the acceptance gate8889What the five landmines have in common: **a clean openpyxl round-trip, and LibreOffice opening the90file, prove nothing about whether real Excel can open it**. Those tools are highly tolerant and will91render a corrupt file as normal. **Final acceptance for an xlsx artifact must be a real Microsoft92Excel actually opening it** (or a byte-level comparison against a known-good original); never take a93permissive tool's success as a pass. (This echoes verification-discipline: verify in the user's real94environment, not through a lenient proxy.)9596### Rule 4: Inherit the whole cell — write the style with the value at the source, leave no "patch the styles later" step9798When you want to "copy a template cell's complete appearance (borders / fill / number format / font)99and only swap the value inside," **write the style index and the value into the new cell in one step**100(`<c r=.. s=template's style index>value</c>`). **Do not** "write values first and patch styles101afterwards" — that second pass will eventually miss cells (especially blank ones that still carry102borders or fill). **Blank cells inherit too** (a bordered empty cell still gets written as103`<c r=.. s=S/>`; do not skip it). Nail it in one pass — there is no second pass to forget.104105### Rule 5: Inherit number formats per column, never blanket one format across the sheet106107Different columns in one table usually carry different number formats (thousands separators here,108percentages there, dates in a third). When filling values, **each column inherits the number format of109its corresponding template column**; do not apply one format to the whole sheet. A broken format does110not change "the value is right" but it does change "this is correct" (see verification's111multi-dimensional comparison).112113### Rule 6: Structural insertion means "insert + shift," never "overwrite"114115When adding a column, a row, or a period (a monthly report gaining one more month column, a chart116gaining one more data point), you **insert and push everything after it along** — you do **not117overwrite some existing position**. Overwriting quietly eats whatever was there. Inserting a column →118every column at or after the target shifts by `+1`; a chart's sliding window → the new data point is119**added and the window shifts**, rather than clobbering the oldest entry.120121### Rule 7: Move live objects (pivots/charts) verbatim + "refresh on load"; never rebuild them with a library122123Live objects like pivot tables and charts are almost guaranteed to come out distorted — or vanish —124when a library rebuilds them. Instead:125- **Verbatim ZIP-copy**: move the pivot/chart parts across untouched (caches included) and change only126 the **source data** they point at.127- Set the **refresh-on-open** flag (`refreshOnLoad` for pivots) so Excel recomputes them from the new128 data when the file opens — you never compute their contents by hand.129- As a bonus this is faster than the library route (no cost of rebuilding large objects).130131132### Rule 8: The physics of reading big files — stream, never materialize a whole sheet133134Rules 1–7 cover "how to write without breaking things"; this one covers "how to read without blowing135up." An xlsx's XML body is often far larger than the file itself (compression is very effective), and136**a mainstream library's normal load consumes roughly 50 times the file size in memory** (the official137documentation says so outright: a 50MB file → 2.5GB of memory). For a worksheet in the hundreds of138megabytes, loading the whole sheet or building a whole-sheet dictionary of cell objects **is139guaranteed to exhaust memory. That is a documented property of the library, not your bug.**140141- **Always stream a large sheet**: read-only lazy loading + row-by-row iteration (iter_rows), which142 holds memory nearly constant; **never materialize the whole sheet** (no whole-sheet cell dict, no143 "read it all into a list first, then process").144- **Need repeated lookups → go to disk, not to memory**: stream it into an embedded database (SQLite,145 say — a single file, zero dependencies) and then point-query by cell (microseconds, near-zero146 memory). **Never pickle the whole dictionary as your persistence layer** — loading it back rebuilds147 the whole thing and the memory peak reappears exactly as before, which makes the work pointless.148- **Peak memory must be pinned by an assertion**: peak RSS on the big-file path goes into the149 regression baseline (see engineering-economy Rule 12). "Constant memory" is a guarantee held down by150 a breaking test, not a claim.151152---153154## Case files from this project (supporting evidence, not required for the general rules)155156- **The core module, a string-level XML surgery module**: one financial-reporting automation project157 turned all of the above into a "minimal string-level" editing module. Its docstring says so from the158 first line: "**change only the target `<c>`'s string; the `<worksheet>`/`<workbook>` root elements,159 every namespace declaration, and every other cell are left untouched — never parse a whole sheet's160 root element**."161- **All five landmines were struck for real, by the owner, during acceptance in real Excel**:162 - Rule 2-①: ET parsing a whole sheet turned `mc` into `ns1` and deleted the `xmlns:x14ac` that was163 referenced from the `mc:Ignorable="x14ac"` attribute value → `<worksheet>` corrupt at row 2164 column 0, `0x808c0002` (the namespace-corruption detector is the dedicated check for exactly this165 landmine: does every prefix referenced by Ignorable have a declaration, and are there leftover166 `ns0/ns1` rewrites?).167 - Rule 2-②: `ET.tostring(encoding="unicode")` emits no declaration → Excel reports a part XML error;168 the declaration-completion function at the write boundary adds `<?xml … standalone="yes"?>` as the169 zip is written.170 - Rule 2-③: the formula-cache backfill function exists purely to fill in cached values for cells171 that have `<f>` but no `<v>`.172 - Rule 2-④: the dead-name filter strips invalid `<definedName>` entries at the string level, and173 removes the entire `<definedNames>` container when they all go.174 - Rule 3: for that same corrupt file, "LO/openpyxl tolerate it = false green" is written verbatim in175 the comments of the declaration-completion function — only real Excel catches it.176- **Rules 4/5**: the whole-cell inheritance writer writes a cell in one step (`s=` takes the entire177 style index from the corresponding cell of the April template, overwriting only value/formula;178 blanks inherit as whole cells too), under the iron rule "there is no separate 'patch the styles'179 step to miss"; the cell-by-cell comparator's column-anchor pairing aligns columns and compares them180 one by one (number formats included).181- **Rule 6**: the column-shift function (after inserting, every col ≥ at_col shifts by `+count`), the182 in-place region replacement function (swap a rectangular region while preserving column order), and183 "add + shift" for a new month column; the chart's sliding window works the same way.184- **Rule 7**: the three pivot-table deliverables use verbatim ZIP-copy + `refreshOnLoad` (faster than185 an openpyxl rebuild and lossless); the audit explicitly records "pivot recomputes live via186 refreshOnLoad."187- **Rule 8**: one 296MB (uncompressed) dependency sheet — building a whole-sheet dictionary of a188 million cell values was OOM-killed twice in a row; after switching to "read_only + iter_rows189 streaming → batch writes into SQLite → cross-references via point lookups," the same sheet seeded in190 158.5 seconds with zero OOM and constant memory. Peak RSS was then locked in as a baseline assertion191 plus a breaking test ("deliberately materialize the whole sheet → it must bark"). The 50x memory law192 only came to light afterwards, by reading the official documentation: everyone hits this. Checking193 external experience first (engineering-economy Rule 1) would have saved both OOMs.