LlmFiles
Converter to Markdown
LlmFiles/Guides/XLSX to Markdown

XLSX to Markdown

A workbook is not a document, it is a small database with a theme. Markdown has no concept of sheets, so a converter has to decide what one means — and everything a cell does not say out loud is left behind: the currency format, the merged banner across the top, the leading zeros in an ID column. Below is what LlmFiles actually returns for real .xlsx files, including the parts that come out wrong.

Updated 24 Oct 2026 · LlmFiles

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 fromWhat appearsWhy
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 rangetext in the top-left cell, NaN in the restThe value is stored once, in the first cell of the merge
A formula with no cached resultNaNNothing computed the value — see the next section
A completely empty sheeta 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 producedCell with =B2*C2Result
Saved by Excel, LibreOffice or a Google Sheets exportexpression + cached value58.50, correct
Written by openpyxl, xlsxwriter or pandasexpression onlyNaN — 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 ExcelIn MarkdownWhat happened
$48,200.0048200Currency and thousands separator dropped
9.0%0.09Percent formatting dropped — multiply by 100
Date cell, 24/10/20262026-10-24ISO 8601 — an improvement
2026-10-31 typed as text2026-10-31Text stays text
ID 007 stored as text7Inferred as a number
3.10 stored as text3.1Type inference again
TRUE / FALSETrue / FalsePython booleans
Leading zeros are the expensive loss. ZIP codes, SKUs and account numbers are stored as text to keep them; a column of 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

  1. Drop the all-NaN rows — the script below.
  2. Check the header: if row 1 was data, it is now the column names.
  3. Replace pipes inside values, or the columns shift.
  4. Multiply percent columns by 100 and say so around the table.
  5. Split sheets wider than about ten columns.
  6. 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.

Frequently asked questions

Do I get one table per sheet?

Yes — each worksheet becomes a heading with the sheet name and one pipe table, in workbook order. The ZIP route returns one .md per input file, so several sheets stay in one document.

Why is my first data row now the header?

Because Markdown needs a header and an XLSX has no header cells, so row 1 is used verbatim. On a sheet without headers that record stops being data — insert a header row in Excel first.

Where do the NaN cells come from?

Pandas filling gaps: empty rows inside the used range, merged ranges (the value lives in the top-left cell), and formula cells with no cached value. An empty sheet comes out as a stray empty table.

Are Excel formulas evaluated?

No — only a cached result computed by a spreadsheet application is read. Files saved by Excel or a Sheets export carry it; files written by openpyxl, xlsxwriter or pandas usually do not, and those cells arrive as NaN.

What happens to dates, currency and leading zeros?

Real date cells become ISO 8601, which helps. Currency and percent formats are dropped, so $48,200.00 becomes 48200 and 9.0% becomes 0.09. Numeric-looking text is inferred, so 007 becomes 7.

What are the limits, and can this run in a pipeline?

Free: 5 files a day from one IP, up to 25 MB each, no sign-up. Pro is not being sold yet. Automation works via POST /api/convert, /api/convert/batch and /api/convert/zip.

Convert a file

Drag a document into the window and get Markdown you can read, diff and feed to a model. Free: 5 files a day, up to 25 MB.