The top-level value picks the shape
Three branches, and no setting to choose between them:
- An array of objects becomes a pipe table — one row per record, the header row built from the keys.
- An object becomes headings and bullet lists: each scalar is
- **key:** value, a nested object gets its own heading, and an array of objects inside it becomes a table. - Anything else — an array of numbers, an empty array, a bare string — is dumped back as a fenced
jsonblock, unreformatted.
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:
- Depth. The heading level is
min(depth + 2, 5), so the fourth level and everything under it render as#####: a seven-level file produced eight headings, four of them at level five and none at level six. The outline a chunker sees is flatter than the data. - Flat lists inside an object. A thirty-item array of strings became twenty bullets and the line
*... 10 more*. Arrays of objects are not affected; only lists of plain values are capped. - Mixed arrays.
[{"a": 1}, "scalar", {"a": 3}]became a one-column table with the scalar in theacolumn, which reads as if the record had that value. Keep object arrays homogeneous. - Empty containers.
[]and{}come back as fenced blocks under the file-name heading — there is nothing to tabulate, so nothing is tabulated.
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.
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.