JSON to Markdown: Convert Data to Tables and Docs
September 11, 2026 · 10 min read
JSON to Markdown: Convert Data into Tables and Docs
Converting JSON to Markdown means one of two things. Either an array of objects becomes a table, or a nested object becomes a document with headings and lists. This tutorial states the mapping rule for each, then gives you working Python, jq, and Node snippets that produce Markdown you can paste anywhere.
Two Kinds of JSON, Two Kinds of Output
Most online converters hide the rule they apply, which is why their output surprises people. There are only two sensible mappings, and you should pick one before you write any code.
Flat records become a table. If your JSON is an array of objects that share the same keys, each object is a row and each key is a column. This is the same problem as CSV to Markdown table with a different parser on the front.
Nested data becomes a document. If your JSON is an object with objects and arrays inside it, keys become headings and scalar values become list items. Arrays of scalars become bullet lists, and arrays of objects become tables. Depth in the JSON becomes heading level in the Markdown.
A third case isn't a conversion at all: you just want to show JSON inside a Markdown file. That takes a fenced code block with the json identifier, which our Markdown code block guide covers in full.
Here's the sample input used for every example below.
{
"project": "Atlas",
"version": "2.4.0",
"maintainers": ["Ana", "Luis"],
"releases": [
{ "tag": "2.4.0", "date": "2026-08-30", "stable": true },
{ "tag": "2.3.1", "date": "2026-07-12", "stable": false }
]
}
How Can I Convert JSON to a Markdown Table?
Take the releases array. Three keys, two objects, so the table has three columns and two data rows.
| tag | date | stable |
|---|---|---|
| 2.4.0 | 2026-08-30 | true |
| 2.3.1 | 2026-07-12 | false |
The rules come from the GFM tables extension. The delimiter row is mandatory, the header must have the same number of cells as the delimiter row, and a data row with fewer cells gets empty ones added. Rows with more cells have the extras dropped silently, which is the first thing to check when a column goes missing.
Four gotchas bite every converter.
- Pipes inside values. A
|in a cell ends the cell. Escape it as\|. - Newlines inside values. A table row is one line. Replace
\nwith a space or<br />. - Null vs empty string. Decide up front whether
nullprints as an empty cell or the word null. Empty is usually what readers want. - Nested objects in a cell. A value like
{"lat": 51.5}has no table form. Flatten to dot-notation columns (location.lat) first, or stringify it.
Flattening deserves one example because it's the step most people skip. An object like {"name": "Ana", "location": {"city": "Lima", "lat": -12.05}} becomes a row with the columns name, location.city, and location.lat. Do it before you build the table, and every nested value gets its own column instead of a blob of braces in one cell.
Alignment and column widths are cosmetic; the Markdown table guide shows the colon syntax if you want numbers right-aligned.
Convert with a Python Script
Python's standard library is enough. The json module maps null to None, true to True, and false to False, so print booleans through json.dumps if you want lowercase in the table.
import json
def cell(v):
if v is None:
return ""
if isinstance(v, str):
return v.replace("|", "\\|").replace("\n", " ")
return json.dumps(v)
def table(rows):
headers = list(rows[0].keys())
lines = ["| " + " | ".join(headers) + " |",
"|" + "---|" * len(headers)]
for r in rows:
lines.append("| " + " | ".join(cell(r.get(h)) for h in headers) + " |")
return "\n".join(lines)
with open("atlas.json") as f:
data = json.load(f)
print(table(data["releases"]))
That prints the table shown above. r.get(h) rather than r[h] means a row missing a key produces an empty cell instead of a crash.
For the nested-document case, add a recursive function that walks the structure.
def to_md(node, depth=1):
out = []
if isinstance(node, dict):
for k, v in node.items():
if isinstance(v, (dict, list)):
out.append(f"{'#' * min(depth, 6)} {k}\n")
out.append(to_md(v, depth + 1))
else:
out.append(f"- **{k}**: {cell(v)}")
elif isinstance(node, list):
if node and all(isinstance(i, dict) for i in node):
out.append(table(node))
else:
out.extend(f"- {cell(i)}" for i in node)
return "\n".join(out) + "\n"
print(to_md(data))
Run it on the sample and you get this.
- **project**: Atlas
- **version**: 2.4.0
# maintainers
- Ana
- Luis
# releases
| tag | date | stable |
|---|---|---|
| 2.4.0 | 2026-08-30 | true |
| 2.3.1 | 2026-07-12 | false |
We prefer this explicit approach over a library for one-off jobs, because the mapping rule sits in 15 lines you can read. The limitation is obvious: min(depth, 6) caps headings at H6, so JSON nested seven levels deep flattens at the bottom. Two tweaks people usually want: start depth at 2 so the file's own title stays the only H1, and loop over sorted(node.items()) if the key order in the file is random.
A special case worth naming: if your file is a JSON Schema rather than data, don't write this yourself. The jsonschema2md package on PyPI reads the schema's properties, type, and description fields and writes a reference page with one section per property.
The jq one-liner
If the data is already on the command line, jq builds a table in one command. It reads the keys from the first object, prints the header and delimiter rows, then one line per object.
jq -r '(.releases[0] | keys_unsorted) as $h
| ($h | join(" | ") | "| \(.) |"),
($h | map("---") | join(" | ") | "| \(.) |"),
(.releases[] | [.[$h[]]] | map(tostring) | join(" | ") | "| \(.) |")' atlas.json
-r writes raw strings instead of quoted JSON strings, keys_unsorted keeps the columns in file order, and "\(.)" is jq's string interpolation. tostring turns null into the word null; swap it for (. // "" | tostring) to print blanks, keeping in mind that // also blanks out false.
jq won't escape pipes for you. Pipe the output through sed 's/|/\\|/g' on the value fields if your data contains them, or handle it in Python instead.
The Node Route: json2md
The npm package json2md solves a slightly different problem. It converts a JSON description of a document (an array of h1, p, ul, table, code objects) into Markdown. That makes it ideal when your code already knows the structure it wants.
const json2md = require("json2md");
const data = require("./atlas.json");
console.log(json2md([
{ h1: data.project },
{ p: `Version ${data.version}` },
{ h2: "Maintainers" },
{ ul: data.maintainers },
{ h2: "Releases" },
{ table: { headers: ["tag", "date", "stable"], rows: data.releases } }
]));
Install with npm install json2md. The call above prints an H1, a version line, a bullet list of maintainers, and the releases table. Supported elements include h1 to h6, p, blockquote, img, link, ul, ol, code, table, and hr. The table element takes headers plus rows as objects keyed by header name, exactly the shape an API response usually has.
Try the Converted Output in the Editor
The document below is what the Python to_md function produces from the sample. Edit a cell, add a pipe, or delete the delimiter row to see how the table reacts.
The Markdown to HTML converter shows the resulting <table> markup, which is handy when the Markdown is headed for a CMS that expects HTML.
Is JSON or Markdown Better for LLMs?
For prompts, Markdown usually wins. Tables and headings cost fewer tokens than the equivalent JSON with its quotes, braces, and repeated keys, and models are trained on a great deal of Markdown. The same 100 rows as a Markdown table typically take far fewer tokens than as a JSON array. The keys appear once in the header instead of once per row. The exact ratio depends on the tokenizer and key lengths.
For structured output from a model, JSON is better because you can parse and validate it. The pattern most teams settle on is Markdown in, JSON out. Our Markdown in AI explainer goes into why chat models default to Markdown for their replies.
One honest disadvantage of Markdown here: it has no schema. A JSON document can be validated against a JSON Schema; a Markdown table can't tell you that a column is missing. Keep JSON as the source of truth and generate the Markdown from it.
Common Conversion Mistakes
Forgetting the delimiter row. Without |---|---| the header and rows render as plain text with pipes in it. GFM requires the row, and the number of cells must match the header.
Letting str(True) into the table. Python prints True, JavaScript prints true. Route booleans through json.dumps so the Markdown matches the source data.
Converting deeply nested JSON to a single table. Flatten first, or split it into one table per array. A table cell holding a blob of braces, or JavaScript's default object-to-string text, helps nobody.
JSON to Markdown FAQ
JSON to Markdown is a mapping decision followed by a small amount of code. Arrays of objects become tables, nested objects become headings and lists, and the snippets above cover Python, jq, and Node. If you only need it once, the Python script is the fastest path; if it runs in a build, json2md keeps the document structure explicit. Paste the output into the editor to confirm the delimiter row and pipe escaping came through before you ship it.