LlmFiles
Converter to Markdown
LlmFiles/Guides/JSON to Markdown

JSON to Markdown

A JSON file has no document structure — only a data structure — so a converter has to invent a shape for it. LlmFiles picks the shape from the top-level value and then fills it in without trimming: every record becomes a row, the header is the union of every key in the file, and no cell is cut at a character limit. Since v1.30 that holds for the whole file, including JSON Lines, files that start with a byte-order mark, and files that turn out not to be JSON at all. The numbers below come from a 470 KB array of 5,000 records and twenty small probes pushed through the live converter; the end of the page lists the four places where the shape still stops.

Updated 2 Nov 2026 · LlmFiles · Russian version

The top-level value picks the shape

Three branches, and no setting to choose between them:

The first rows of a sixty-record file, exactly as the converter returned them:

| id | service | region | cost_usd |
| --- | --- | --- | --- |
| 1 | svc-1 | apac | 0.37 |
| 2 | svc-2 | us | 0.74 |

The heading above the table is the uploaded file name, not anything from the data, so a chunk cut out of the Markdown still says which file it came from.

One row per record, and a fresh table every thousand

A 470,707-byte array of 5,000 records came back as 166,249 bytes of Markdown in 0.04 seconds — 84,811 tokens in o200k_base against 199,634 for the JSON, so 42% of the original. It holds five tables, 5,000 data rows in total, and not one summarised or dropped record.

The split is a rendering decision, not a cap: a single 5,000-row pipe table is slow to draw and frequently cut by editors and chat windows. Between chunks the converter writes one italic line, and that line is the only thing added to the data:

*Rows 1001–2000 of 5000 (the table continues):*

Every chunk repeats the header, so a chunk read on its own is still a valid table. A sixty-record file gets no such lines at all — one table, 60 rows, 1,841 bytes.

The header is the union of every key

Keys are collected from every record in the order they first appear, so a field missing from the first record still gets a column. Three records with four distinct keys between them produce a four-column table:

| id | name | tier | owner |
| --- | --- | --- | --- |
| 1 | alpha |  |  |
| 2 | beta | pro |  |
| 3 | gamma |  | ops |

The empty cells are the records that do not carry the key. That is the case that used to hurt most: an API response whose first item has no optional field yet lost every field that appeared later, silently, with no column and no warning.

Cells keep their content, including the characters that break tables

A 400-character summary arrived as all 400 characters, in a 458-byte document. The two characters that used to wreck the grid are escaped instead of passed through — this row came from a value /a|/b|/c and a value with a line break in it:

| rule | paths | note |
| --- | --- | --- |
| allow | /a\|/b\|/c | first line<br>second line |

A pipe becomes \|, a newline becomes <br>, and an object or list inside a cell is serialised as compact JSON. Numbers are written as JSON numbers rather than through Python's str(): 0.30000000000000004 keeps all seventeen significant digits, the twenty-digit integer 12345678901234567890 stays an integer instead of becoming a float, 1e-07 and 1e+20 keep their exponent form, and 1.0 stays 1.0. Booleans print as true and false, not as True and False.

null is the one value that still disappears

In a table an empty cell does double duty: it means both “this record has no such key” and “the value is null”. This measured row had paid: true, refunded: null, and numbers at both ends of the float range:

| id | paid | refunded | ratio | micro | big |
| --- | --- | --- | --- | --- | --- |
| 1 | true |  | 0.5 | -0.001 | 12345678901234567890 |

Outside tables the distinction survives: in the object branch the same fields print as - **paid:** true and - **refunded:** null. If you need null spelled out inside a table, the script at the end of this page writes it as text.

JSON Lines, the byte-order mark, and files that are not JSON

A .jsonl or .ndjson file is read one value per line and rendered as a single table, with a note above it: > **Note:** Read as JSON Lines: 4 values, one per line, rendered as one array. The same content saved as .json is recovered the same way, so a mislabelled export does not collapse into a fenced blob.

A UTF-8 byte-order mark — what PowerShell's Out-File -Encoding utf8 and several editors write — is stripped before parsing: a 32-byte file with a BOM became a two-line bullet list with no invisible character left in the first key.

Invalid JSON is no longer dressed up as a success. A 23-byte file with a double comma returns HTTP 200, the raw text in a fenced block, and a warning in both the Markdown and the JSON response:

# broken.json

> **Warning:** This file is not valid JSON — Expecting value at line 1, column 20. It is shown as-is below; nothing was reformatted.

```json
{"a": 1, "b": [1,2,,3]}
```

The message carries the line and the column, so the fix is a jump in a text editor rather than a bisect. JSON with // comments gets the same treatment — Expecting property name enclosed in double quotes at line 2, column 3 — because comments are not JSON, and passing the file through as if they were is how bad data reaches a prompt. In a pipeline, read the warning field of the API response: it is empty on a clean parse and holds that sentence when something is off.

Where the shape still stops

Four cases, all measured on the probes above:

There is also a ceiling on the table as a whole. If the Markdown would pass 16 MB, the table stops and says so in one line — *Table stopped at row X of Y: the Markdown would exceed 16 MB. Split the file or convert it locally with the CLI.* — rather than returning half a table without comment. No probe here came near it: 5,000 records produced 166 KB, so treat the number as a guard rail, not a limit you will meet.

A single table when you need one

The five tables exist because browsers are slow; a retrieval pipeline would rather have one. This script produces the same rows in one table, keeps null as text, does not cap flat lists at twenty items, uses heading levels down to six, and exits non-zero on invalid JSON instead of returning a warning:

#!/usr/bin/env python3
"""JSON (or JSON Lines) to Markdown: one table, every row, every key, null kept."""
import json
import sys


def cell(value):
    if value is None:
        return "null"
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return json.dumps(value)
    if isinstance(value, (dict, list)):
        value = json.dumps(value, ensure_ascii=False, separators=(", ", ": "))
    text = str(value).replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
    return text.replace("|", "\\|")


def table(rows):
    keys = []
    for row in rows:
        for key in row:
            if key not in keys:
                keys.append(key)
    out = ["| " + " | ".join(keys) + " |",
           "| " + " | ".join("---" for _ in keys) + " |"]
    for row in rows:
        out.append("| " + " | ".join(cell(row[key]) if key in row else "" for key in keys) + " |")
    return "\n".join(out)


def walk(node, depth, out):
    prefix = "#" * min(depth + 2, 6)
    if isinstance(node, dict):
        for key, value in node.items():
            if isinstance(value, dict):
                out.append(f"\n{prefix} {key}\n")
                walk(value, depth + 1, out)
            elif isinstance(value, list) and value and all(isinstance(i, dict) for i in value):
                out.append(f"\n**{key}:**\n\n" + table(value) + "\n")
            elif isinstance(value, list):
                out.append(f"\n**{key}:**\n")
                out += [f"- {cell(item)}" for item in value]
            else:
                out.append(f"- **{key}:** {cell(value)}")
    elif isinstance(node, list):
        if node and any(isinstance(i, dict) for i in node):
            out.append(table([i if isinstance(i, dict) else {"value": i} for i in node]) + "\n")
        else:
            out += [f"- {cell(item)}" for item in node]
    else:
        out.append(cell(node))


def main():
    source = sys.argv[1] if len(sys.argv) > 1 else "-"
    raw = sys.stdin.read() if source == "-" else open(source, encoding="utf-8-sig").read()
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        try:
            data = [json.loads(line) for line in raw.splitlines() if line.strip()]
        except json.JSONDecodeError:
            sys.exit(f"not valid JSON: {exc.msg} at line {exc.lineno}, column {exc.colno}")
    name = "document" if source == "-" else source.replace("\\", "/").rsplit("/", 1)[-1]
    out = [f"# {name}\n"]
    walk(data, 0, out)
    text = "\n".join(out)
    while "\n\n\n" in text:
        text = text.replace("\n\n\n", "\n\n")
    print(text.strip() + "\n")


if __name__ == "__main__":
    main()

Run it as python3 json_table.py data.json > data.md, or with no argument to read stdin. On the same probes: the 5,000-record file became one header and 5,000 rows in 165,790 bytes with no continuation lines; the thirty tags became thirty bullets; the seven-level file reached ######; the heterogeneous file produced the same four columns as the converter; and the invalid file exited 1 with not valid JSON: Expecting value at line 1, column 20.

Which shape does a model actually want?

Record lists belong in a table: one row per entity is the easiest thing to retrieve, compare and cite. Configuration trees are better as headings and bullets — a heading is a natural chunk boundary — which is what the object branch already does. Lock files should not be converted at all: a 56,838-byte package manifest came back as 51,029 bytes of Markdown in 203 sections and 14,264 tokens, barely smaller than the JSON and no easier to chunk, because it is mostly hashes and URLs. Keep the fenced block or skip the file. The CSV guide covers the tabular case where every value is already flat, and HTML to Markdown covers the opposite problem — stripping a page down to its content first.

Free: 25 conversions a day from one IP, 25 MB per file, no account and nothing to buy — there is no paid plan, no key and no invoice. Files are parsed on the same server that runs LlmFiles and are never forwarded to a third-party conversion cloud. Automation runs over the HTTP API: POST /api/convert for one file, /api/convert/batch for up to twenty in one request, /api/convert/zip for an archive, where the whole archive counts as one conversion. The converter itself needs no account.

Frequently asked questions

My array has 5,000 records and the output is five tables. Why?

One table is chunked at 1,000 rows so no renderer chokes on it. Each chunk repeats the header and is introduced by an italic line such as *Rows 1001–2000 of 5000 (the table continues):* — all 5,000 records are there. For one continuous table, use the script on this page or the local CLI.

Why are some cells empty?

The header is the union of every key in the file, so a record that does not carry a key gets an empty cell. null also renders as an empty cell in tables — that is the one place where a value still disappears.

Where did the fifty-row limit go?

It was removed in v1.30.0. Earlier versions of this page described the table as capped at fifty rows, with columns from the first object only and cells cut at a hundred characters. Measured today: a 5,000-record file keeps 5,000 rows, four distinct keys give four columns, and a 400-character value arrives whole.

Does the converter validate my JSON?

Yes, and it reports the position. An invalid file returns HTTP 200 with a > **Warning:** line in the Markdown and the same sentence in the warning field of the JSON response, naming the line and column. JSON with // comments is not JSON and is flagged the same way.

Can I convert JSON Lines or NDJSON?

Yes — .jsonl and .ndjson are read one value per line and rendered as one table, with a note above it. A .json file that holds one value per line is detected too.

Why did null become an empty cell?

Tables print null as an empty cell so the grid stays clean when it is pasted into a spreadsheet; bullet lists print the word null. If you need it in a table, the script on this page writes it as text.

Where do the limits come from?

Free tier: 25 conversions a day per IP, 25 MB per file, no sign-up and no paid plan. The API endpoints and their exact payloads are in the API documentation.

Convert a file

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