What one workbook becomes
Every worksheet turns into a level-2 heading with the sheet name, then a pipe table. Sheets keep workbook order, and a leftover sheet from a template still gets its heading. A 6 KB file with three sheets came back unedited:
## Q3 revenue
| Region | Revenue | Growth | Owner |
| --- | --- | --- | --- |
| EMEA | 48200.0 | 0.09 | A. Ivanova |
| APAC | 21700.0 | 0.31 | K. Tanaka |
| Americas | 39150.0 | -0.04 | J. Smith |
| NaN | NaN | NaN | NaN |
| Total | NaN | NaN | NaN |
| NaN | NaN | NaN | NaN |
| Unaudited, internal use only | NaN | NaN | NaN |
## Notes
| Prepared by finance |
| --- |
| Deadline: 2026-10-31 |
The structure is honest: sheet names as boundaries, rows as rows. The damage is in the detail — the NaN cells and the formatting that vanished. Both are easier to fix at the source than in the Markdown.
The first row becomes the header
Markdown needs a header row, and unlike DOCX — where Word has no header cells — XLSX row 1 is taken verbatim. Correct for a report, wrong for a data dump; a headerless three-by-two sheet came back like this:
## Sheet
| 1 | 2 | 3 |
| --- | --- | --- |
| 4 | 5 | 6 |
The record 1 2 3 is now the column names and no longer data. Insert a header row in Excel before converting: moving the row back afterwards is easy, noticing that it needs moving is not — the table looks valid either way.
NaN: where Excel says nothing
Sheets are read with pandas, which fills gaps with NaN — a string, not an empty cell. Four sources:
| Where it comes from | What appears | Why |
|---|---|---|
| An empty row inside the used range | | NaN | NaN | NaN | | Excel keeps a row that once had formatting; pandas reads a record of blanks |
| A merged range | text in the top-left cell, NaN in the rest | The value is stored once, in the first cell of the merge |
| A formula with no cached result | NaN | Nothing computed the value — see the next section |
| A completely empty sheet | a stray | and | | | One empty frame still becomes one table |
On the workbook above that meant two all-NaN rows and six NaN cells: valid Markdown, but a retriever indexes those rows as content and a reader sees a broken table.
Formulas: only the last computed value
A formula is stored twice — as an expression, and as the value Excel computed when it last saved the file. The converter reads the value, so the same sheet converts differently depending on who wrote it:
| How the file was produced | Cell with =B2*C2 | Result |
|---|---|---|
| Saved by Excel, LibreOffice or a Google Sheets export | expression + cached value | 58.50, correct |
| Written by openpyxl, xlsxwriter or pandas | expression only | NaN — the number is not in the file |
Nothing is evaluated, which is fair: running formulas from an untrusted file is arbitrary code execution. In practice write the value, not the formula — compute the column before pandas to_excel, write numbers in openpyxl, or open the file in Excel once and save it to fill the cache.
What the formatting was carrying
A Markdown cell holds text and nothing else, so number formats are dropped: a column that reads $48,200.00 and 9.0% arrives as 48200 and 0.09. Those are the stored values, but the readable form is gone:
| In Excel | In Markdown | What happened |
|---|---|---|
$48,200.00 | 48200 | Currency and thousands separator dropped |
9.0% | 0.09 | Percent formatting dropped — multiply by 100 |
Date cell, 24/10/2026 | 2026-10-24 | ISO 8601 — an improvement |
2026-10-31 typed as text | 2026-10-31 | Text stays text |
ID 007 stored as text | 7 | Inferred as a number |
3.10 stored as text | 3.1 | Type inference again |
TRUE / FALSE | True / False | Python booleans |
007, 0042 arrives as 7 and 42, and the identifiers no longer join against anything. Keep a quoted copy of that column before it reaches Markdown.A pipe or a line break in a cell
Markdown splits cells on |. A cell containing a|b turns three columns into four and shifts everything right:
| Region | Revenue | Note |
| --- | --- | --- |
| with | pipe | NaN | NaN | <- three cells became four
Line breaks are handled: two lines in one cell are written with the break escaped as a literal \n, so the row survives. Pipes are not — replace them with / in the source, or escape them as \| afterwards.
Wide sheets stay wide
A 30-column sheet produced one table with 30 columns and one-word rows: no wrapping, no splitting, no transposing. On screen that is a horizontal scroll; in a prompt it is a row where column 27 sits far from its header, which is where models start misreading values. If the table is meant to be read, split it by column group in Excel.
When CSV is the better input
CSV drops sheets and formatting, so the NaN and currency problems disappear with it. Two measured caveats: a semicolon-separated file is not split on the semicolon (each row arrived as one cell), and an Excel-written UTF-8 BOM leaks into the first header cell. Comma-separated without a BOM converts cleanly. Data dumps and exports are usually better as CSV; multi-sheet reports are better as XLSX.
A cleanup pass before the text reaches a model
- Drop the all-NaN rows — the script below.
- Check the header: if row 1 was data, it is now the column names.
- Replace pipes inside values, or the columns shift.
- Multiply percent columns by 100 and say so around the table.
- Split sheets wider than about ten columns.
- Keep the sheet names — in an XLSX they are the only free chunk boundary.
import re, sys
SEP = re.compile(r"^\|(\s*:?-{3,}:?\s*\|)+$") # | --- | --- |
def clean(md):
out, dropped, cleared = [], 0, 0
for line in md.split("\n"):
s = line.strip()
if s.startswith("|") and s.endswith("|") and not SEP.match(s):
cells = [c.strip() for c in s.strip("|").split("|")]
if all(c in ("NaN", "") for c in cells):
dropped += 1 # nothing but filler
continue
if "NaN" in cells:
cleared += cells.count("NaN")
line = "| " + " | ".join("" if c == "NaN" else c for c in cells) + " |"
out.append(line)
return "\n".join(out), dropped, cleared
for path in sys.argv[1:]:
text = open(path, encoding="utf-8").read()
new, d, c = clean(text)
open(path, "w", encoding="utf-8").write(new)
print(f"{path}: {d} blank row(s) dropped, {c} NaN cell(s) cleared")
Run on the Markdown above it dropped two blank rows and cleared six NaN cells, leaving the merged-banner text and the total row in place — deleting every NaN row blindly would take those with it.
Privacy, limits and the API
The workbook is parsed on our server: a temporary copy exists for the length of the conversion and is deleted right after it. Nothing goes to a third-party spreadsheet or OCR service, file contents never reach analytics, and for financial models and anything under NDA there is an on-prem install inside your own network.
Free tier: 5 files a day from one IP, up to 25 MB each, no account. Pro (300 MB, no daily cap) is not on sale yet — we are not taking money for it; leave a request on the pricing page and we will write when launch pricing opens. For pipelines: POST /api/convert returns JSON with a markdown field, POST /api/convert/batch takes up to 20 files in one request, and POST /api/convert/zip returns a ZIP of .md files — one per input file, not per sheet. Working examples are in the API documentation; the converter needs no sign-up.