How to Convert JSON to Markdown
Convert JSON to markdown tables with Python, jq, or a code fence — how to flatten nested objects, escape pipes, document JSON Schema, and know when a code block is the better answer.
Short answer: For a JSON array of flat objects, convert it to a markdown table with Python — pd.read_json("data.json").to_markdown(index=False) using pandas and tabulate. For nested JSON, flatten it first with pd.json_normalize() or reshape it with jq. And for JSON that's meant to be read as JSON — a config file, an API response example — the right answer is usually a fenced code block tagged json, not a table at all.
JSON is a tree. Markdown tables are a grid. Most of the difficulty in converting JSON to markdown is deciding which shape your data actually wants, and a lot of the time the answer is "leave it as JSON."
First: Do You Actually Want a Table?
Four common reasons people convert JSON to markdown, and they don't all want the same output:
Documenting an API. You want a table of fields, types, and descriptions — plus a code block showing a real response. Both.
Documenting a config file. You want the JSON shown as JSON with syntax highlighting, plus prose explaining the keys. A table of every key in a deeply nested config is worse than the config itself.
Reporting on data. A JSON array of records that should be a table. This is the clean case.
Changelogs and release notes. Structured release data becoming readable markdown lists. Usually a list, not a table.
If your JSON is a single nested object rather than an array of similar records, stop and consider a code fence. It's covered further down and it's more often correct than people expect.
The Clean Case: Array of Flat Objects
This shape converts to a table directly:
[
{"id": 1, "name": "Ada Lovelace", "role": "Engineer", "active": true},
{"id": 2, "name": "Grace Hopper", "role": "Rear Admiral", "active": false},
{"id": 3, "name": "Alan Turing", "role": null, "active": true}
]
With Python
pip install pandas tabulate
import pandas as pd
df = pd.read_json("data.json")
print(df.to_markdown(index=False))
Output:
| id | name | role | active |
|-----:|:-------------|:-------------|:---------|
| 1 | Ada Lovelace | Engineer | True |
| 2 | Grace Hopper | Rear Admiral | False |
| 3 | Alan Turing | | True |
tabulate is a required dependency of .to_markdown() — without it you get an ImportError. index=False drops the row-number column.
Two things to notice in that output. JSON's true and false came out as Python's True and False, because pandas parsed them into Python booleans. And null became an empty cell. Both are usually fine; if they aren't, cast before converting:
df["active"] = df["active"].map({True: "yes", False: "no"})
df = df.fillna("—")
With jq
If you'd rather not involve Python, jq can emit a markdown table directly:
jq -r '
(.[0] | keys_unsorted) as $cols
| ("| " + ($cols | join(" | ")) + " |"),
("|" + ($cols | map(" --- |") | join(""))),
(.[] | [.[$cols[]] | tostring] | "| " + join(" | ") + " |")
' data.json
Output:
| id | name | role | active |
| --- | --- | --- | --- |
| 1 | Ada Lovelace | Engineer | true |
| 2 | Grace Hopper | Rear Admiral | false |
| 3 | Alan Turing | null | true |
It takes the column names from the first object's keys, in document order (keys_unsorted rather than keys, which sorts alphabetically). tostring handles numbers and booleans — note it renders null as the literal string null, which you may want to replace.
The advantage of the jq route is that it composes with everything else in a shell pipeline, including curl:
curl -s https://api.example.com/users | jq -r '...' > users.md
Reshaping with jq First
The most useful thing about jq here isn't table generation — it's getting the JSON into table shape before you convert it.
Pick and rename columns:
jq '[.[] | {ID: .id, Name: .name, Team: .department}]' data.json
Filter rows:
jq '[.[] | select(.active == true)]' data.json
Dig into a wrapper object. Most APIs don't return a bare array — they return {"data": [...], "meta": {...}}. Extract the array:
jq '.data' response.json
Sort:
jq 'sort_by(.name)' data.json
Chain these into the table command, or write the reshaped JSON to a file and hand it to pandas. Either way, doing the shaping in jq beats hand-editing a markdown table afterward.
Nested JSON: The Hard Problem
Real JSON is nested:
[
{"id": 1, "user": {"name": "Ada", "email": "ada@example.com"}, "tags": ["math", "code"]},
{"id": 2, "user": {"name": "Grace", "email": "grace@example.com"}, "tags": ["compilers"]}
]
There's no correct way to put a tree in a grid. You have to pick a lossy representation.
Option 1: Flatten
pd.json_normalize() turns nested objects into dotted column names:
import pandas as pd, json
df = pd.json_normalize(json.load(open("data.json")))
df["tags"] = df["tags"].apply(", ".join)
print(df.to_markdown(index=False))
| id | tags | user.name | user.email |
|-----:|:-----------|:----------|:------------------|
| 1 | math, code | Ada | ada@example.com |
| 2 | compilers | Grace | grace@example.com |
Nested objects become user.name and user.email columns. Arrays don't flatten into columns — they'd need one column per element and the lengths differ — so join them into a string, as above.
Flattening works well two levels deep. Past that you get column names like config.database.connection.pool.max and a table nobody can read.
Option 2: Nested Lists
For genuinely hierarchical data, a nested markdown list matches the structure better than a table:
- **Ada** (id: 1)
- Email: ada@example.com
- Tags: math, code
- **Grace** (id: 2)
- Email: grace@example.com
- Tags: compilers
Generate it with a short template loop rather than a converter:
import json
for r in json.load(open("data.json")):
print(f"- **{r['user']['name']}** (id: {r['id']})")
print(f" - Email: {r['user']['email']}")
print(f" - Tags: {', '.join(r['tags'])}")
This is more work than running a converter, and it produces output a human will actually read. For anything published in documentation, it's usually the better trade.
Option 3: One Table Per Level
If the nesting is a small fixed set of sub-objects, split into multiple tables with headings between them. A table of users, then a table of their settings, joined by ID. This is how well-written API docs handle nested response objects.
Often the Right Answer: A Fenced Code Block
If the JSON is meant to be read as JSON — an example request body, a config file, an API response — put it in a fenced code block with the language tag:
```json
{
"name": "openmark",
"version": "1.0.0",
"settings": {
"theme": "nord",
"fontSize": 14
}
}
```
Which gives you syntax-highlighted, copy-pasteable, structurally accurate JSON. Nothing is lost, nothing is flattened, and a reader can paste it straight into their own config.
The json language tag is what triggers highlighting. Without it you get monospace grey text. OpenMark highlights fenced JSON in Document view, as do GitHub, GitLab, and most static site generators.
The rule of thumb: if a reader might want to copy the JSON, use a code block. If they want to scan and compare values across records, use a table. Documenting a config file is the first case. Reporting on a dataset is the second. API docs need both — a field table for reference, a code block for a real example.
Escaping Pipes
Same trap as with CSV and Excel conversion, and JSON hits it more often because JSON strings frequently contain type unions, regexes, and shell commands.
A value like "string | null" inside a table cell breaks the row. GFM truncates the extra cells to match the header count, so a cell of data silently vanishes — no error, no warning.
Escape pipes as \| before converting. In jq:
jq -r '
(.[0] | keys_unsorted) as $cols
| ("| " + ($cols | join(" | ")) + " |"),
("|" + ($cols | map(" --- |") | join(""))),
(.[] | [.[$cols[]] | tostring | gsub("\\|"; "\\|")] | "| " + join(" | ") + " |")
' data.json
In Python:
df = df.map(lambda v: v.replace("|", r"\|") if isinstance(v, str) else v)
Two more things to escape while you're there: newlines inside JSON string values, which break a table row entirely — replace them with <br> or a space — and leading #, -, or > characters, which markdown may interpret as block syntax if they start a cell.
The markdown tables syntax guide covers the full set of cell content rules.
Documenting JSON Schema
A JSON Schema file is a description of a data shape, and it converts to a field reference table well because the structure is predictable — properties gives you field names, each with type, description, and often default, and a top-level required array tells you which are mandatory.
Pulling that into a table with jq:
jq -r '
(.required // []) as $req
| "| Field | Type | Required | Description |",
"| --- | --- | --- | --- |",
(.properties | to_entries[]
| "| `" + .key + "` | " + (.value.type // "any")
+ " | " + (if (.key | IN($req[])) then "yes" else "no" end)
+ " | " + (.value.description // "") + " |")
' schema.json
That handles a flat schema. Nested objects and $ref pointers need recursion, at which point you're writing a real generator rather than a one-liner.
Dedicated JSON Schema documentation generators exist for exactly this — they resolve $ref, walk nested definitions, and emit markdown or HTML. If you're documenting a large schema, look for one in your language's package ecosystem rather than extending the jq above; check its docs for current options, since they vary considerably.
For a small schema, generating the table once and then editing the descriptions by hand is usually faster than configuring a generator.
Putting It Together
A workflow that holds up for API documentation:
curlthe real endpoint, save the response.jqto reshape — extract the array, pick fields, rename to human-readable column headers.- Convert to a table with pandas or the
jqrecipe above. - Paste the table into your markdown doc, next to a fenced
jsonblock showing the raw response. - Edit the descriptions by hand. No generator writes good field descriptions.
Then read the result rendered. A table with a swallowed cell or a broken row looks fine in the raw source and obviously wrong in the rendered view — which is the whole argument for checking it in an editor that shows you both. See markdown for developers for more on where this fits in a documentation workflow, and how to convert a web page to markdown if your source is a docs page rather than an API.
Download OpenMark → — $9.99, one-time, native macOS. Syntax-highlighted JSON code blocks and live-rendered tables, so you can check a conversion before it ships.