Sooner or later someone hands you a shared drive full of .doc, .xls and .ppt files and says "move it all to the new formats". Converting one file is easy. Converting 4,000 and knowing nothing silently broke is a migration. This is the runbook I'd use, with the two scripts I tested: a pre-flight / batch / post-flight converter and a round-trip experiment that shows what a LibreOffice-based pipeline keeps and loses.
Step 0: know what "conversion" is in your pipeline
Most free converters (online or self-hosted) call LibreOffice in headless mode. One online converter I checked says so on its own page (conversion uses LibreOffice, so "the output may differ from the original in some details"). Whatever tool you pick, the fidelity of your migration is the fidelity of its engine, so test the engine on a sample of your own files.
Step 1: pre-flight, because the converter will say "success" on junk
Legacy binary Office files are OLE2 containers and begin with the bytes D0 CF 11 E0 A1 B1 1A E1. Modern files are ZIP packages and begin with PK. Plenty of ".xls" files in the wild are really HTML exports, and plenty of ".doc" files are plain text or already .docx. I tested what LibreOffice does with them:
- a plain-text file named
fake.doc→ converted without complaint into a 4,994-bytefake.docx - an HTML file named
web.xls→ converted without complaint into a 5,536-byteweb.xlsx
Both runs reported success. You can end up with a folder of "converted" files that were never Office documents. So check the magic bytes first:
OLE = bytes.fromhex('D0CF11E0A1B11AE1')
ZIP = b'PK\x03\x04'
def preflight(path):
if os.path.getsize(path) == 0: return 'empty file'
head = open(path, 'rb').read(8)
if head.startswith(ZIP): return 'already a zip-based file (renamed .docx/.xlsx/.pptx?)'
if head != OLE: return 'not an OLE2 file (renamed text/HTML/RTF?)'
return None
One online converter's page says the same thing in its own words: it accepts "genuine binary DOC, XLS, and PPT files only" and rejects renamed text files. My pre-flight is deliberately strict; HTML-as-.xls exports are a legitimate case, but they deserve a separate, reviewed path, not the same batch.
Step 2: convert with a private profile and a timeout
def convert(src, outdir, ext, timeout=90):
profile = tempfile.mkdtemp() # own profile: no lock collisions between runs
try:
r = subprocess.run(['soffice', f'-env:UserInstallation=file://{profile}', '--headless',
'--convert-to', TARGET[ext], '--outdir', outdir, src],
capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stderr.strip()[:120]
except subprocess.TimeoutExpired:
return -1, f'timeout after {timeout}s'
finally:
shutil.rmtree(profile, ignore_errors=True)
A separate -env:UserInstallation per call avoids the stale-lock problems you meet when one headless instance is already running; the timeout makes one bad file unable to hang the whole job.
Step 3: post-flight, because exit code 0 is not proof
The output must exist, be a valid ZIP and contain [Content_Types].xml:
def postflight(out):
if not os.path.exists(out): return 'no output file'
try:
z = zipfile.ZipFile(out)
if z.testzip() is not None: return 'corrupt zip'
if '[Content_Types].xml' not in z.namelist(): return 'not an OOXML package'
except zipfile.BadZipFile:
return 'not a zip'
I ran the full script (in the package) on a folder with 3 genuine legacy files and 4 bad ones. Result: the 3 real files were OK (0.2–0.4 s each), and the empty file, the text file, the HTML file and the renamed .docx were all SKIPPED with a reason, written to report.csv.
Step 4: acceptance tests: what does a conversion keep?
I did not want to guess, so I built a fixture per format with known features, converted it to the legacy format and back to the modern one (LibreOffice 24.2), and read each property back with openpyxl, python-docx and python-pptx:
Highlights:
-
Excel: all 20 formulas, three number formats, merged cells, a comment, conditional formatting, data validation, a defined name, column width, a chart, an image and a hidden sheet came back. Every cell value and formula text compared equal. Freeze panes did not (
B2became none). - Word: headings, table, image, header, footer, a bold run, a comment, a tracked insertion, a tracked deletion and a bullet list all survived.
- PowerPoint: 3 slides, speaker notes, a picture, a chart and a table survived.
Two caveats matter more than the 28 greens:
- My legacy files were written by LibreOffice, not by Microsoft Office. A round trip through the same engine is friendlier than converting a file that Office 2003 wrote. Treat this as a harness and a baseline, not a guarantee. The way to get a real answer is to run it on a sample of your own files.
- I did not isolate whether the freeze panes were lost on the way to
.xlsor on the way back. A direct.xlsx→.xlsxpass through the same engine kept them (B2), so it is specific to the.xlsleg in my setup.
The trap: converting back loses data silently
Legacy .xls has a hard limit of 65,536 rows per sheet. I wrote a 70,000-row .xlsx, converted it to .xls and back, and the sheet came back with 65,536 rows. No error. Nothing about it was in the script's output. If any step of your workflow goes through .xls (for a partner who "needs the old format"), add a row count to the acceptance test.
"Modern files are smaller": not always
A common claim is that the modern formats are much smaller. In my fixtures:
| Format pair | Original modern | Legacy | Converted back |
|---|---|---|---|
| xlsx / xls | 159,266 B | 166,400 B | 162,797 B |
| docx / doc | 188,530 B | 176,128 B | 160,774 B |
| pptx / ppt | 191,124 B | 815,616 B | 196,474 B |
Only the PowerPoint pair showed a big gap, which comes from how .ppt stores embedded pictures, and my picture was incompressible noise, so treat the sizes as illustrative. Do not promise "50% smaller" to anyone before you have measured their files.
What an online converter says about itself
For a few files you do not need a script. For comparison, here is one converter page, from its own text (Nov 2026):
| Option | Batch | Limits | Not preserved / caveats | Where it runs |
|---|---|---|---|---|
| The script above | any folder | your timeout | macros are not carried over by this pipeline; you decide on pre- and post-flight | your machine |
| ToolTiny Office Format Converter | up to 10 files, mixed types; several inputs return a ZIP | 20 MB total; 90 s per file; 5 min per job; one failed file fails the whole batch | macros not retained; password-protected files unsupported; fonts, pagination, formulas, charts and presentation effects may differ | uploaded to its server, LibreOffice converts, temp files removed after download or failure |
I did not re-check other converters for this post.
Checklist for your own migration
- Pre-flight: size, magic bytes, passwords, macros (VBA is not converted; keep the originals).
- Convert in an isolated profile with a timeout.
- Post-flight: valid package, then your feature checks (formulas, row counts, headings, slide counts).
- Spot-check by eye: 5% of files, the ugliest ones (fonts, pagination and animations are the usual drift; I did not test them).
- Keep the originals until someone has opened the converted file.
Limits of this write-up
- One engine (LibreOffice 24.2.7.2) and synthetic fixtures. Real files, especially ones saved by old Office versions, will find problems mine did not.
- I did not test macros, passwords, fonts, page breaks, animations or transitions.
- The converter row is what the vendor's page states, not a test of its output.
If your migration found a property that did not survive, tell me which one and I will add it to the acceptance test.

Top comments (0)