JSON to Markdown Table: jq, Python, Online
You have a JSON array from an API, a test run, or a package.json, and you need it as a table in a README, a GitHub issue, or a pull request comment. Converting JSON to a Markdown table looks like a one-liner until a value contains a pipe character, a key is missing from half the rows, or a description has a newline in it. Each of those breaks the table, and GitHub will not tell you why. It just renders a mess.
This guide covers the table syntax that matters, a jq filter and a Python script that handle the edge cases, and the one place pandas gets it wrong.
The syntax GitHub actually needs
A GitHub Flavored Markdown table is a header row, a delimiter row, and one line per row:
The delimiter row is required. Without it you get plain text with pipes in it. Padding the columns so they line up is optional. It makes the raw file easier to read but the rendered table looks the same.
Colons in the delimiter row set alignment:
| Delimiter | Alignment |
|---|---|
--- or :--- | left |
:---: | center |
---: | right |
Three things inside a cell will break the row:
- A literal
|ends the cell early. Escape it as\|. - A newline ends the row. Replace it with
<br>, which GitHub renders inside table cells. - A nested object or array has no table form. Serialize it as JSON or flatten it first.
The sample data
Every example below uses this array. It has the three problems on purpose: next has a nested peer object, sharp has a pipe in its note, and left-pad has a null version and is missing deps entirely.
jq: the naive version and the correct one
The filter you find first takes its columns from the first object:
It ran without an error and it is wrong. The peer and note columns are gone because the first object did not have them. left-pad shows null for a missing field. If note had been in the first object, the pipe in it would have split the sharp row into an extra column.
Save this as table.jq instead:
The reduce collects every key from every row in first-seen order. You could write map(keys_unsorted) | add | unique, which is shorter, but unique sorts, so your name column ends up after deps. The cell function is where the escaping lives. It also avoids the // "" shortcut, because jq treats false as missing there and a boolean column would come out blank. Tested on jq 1.7.1.
Python: skip pandas for this
DataFrame.to_markdown() is the answer you will see most often:
With pandas 3.0.6 and tabulate 0.10.0 that prints:
The flattening of peer.react is nice. The rest is not. Every missing value prints as nan, and the pipe in the sharp row is not escaped, so GitHub renders that row with seven cells and pushes optional into a column with no header. It also needs two packages installed to print some text.
The standard library does it in a dozen lines and gets the edge cases right:
dict.fromkeys keeps first-seen order and drops duplicates, which is the same job the jq reduce does. Booleans come out as true and false through json.dumps, matching the JSON input instead of Python's True.
Comparing the options
| Behavior | Naive jq | table.jq | pandas to_markdown | stdlib script | JSON to Markdown |
|---|---|---|---|---|---|
| Keys from every row | no | yes | yes | yes | yes |
| Column order | first row | first seen | first seen | first seen | first seen |
| Missing or null | null | blank | nan | blank | blank |
| Escapes pipes | no | yes | no | yes | yes |
| Newlines to br tags | no | yes | no | yes | no |
| Nested objects | dropped | JSON string | flattened | JSON string | JSON string |
| Padded columns | no | no | yes | no | yes |
In the browser
For a one-off table, paste the array into the JSON to Markdown converter. It merges keys from every row, escapes pipes, leaves missing and null cells blank, and pads the columns so the raw Markdown is readable in a diff. Paste a single object instead of an array and you get a two-column Key and Value table, which is a quick way to document a config file. Our sample array comes out like this:
One honest gap: it does not convert newlines inside values yet, so a multi-line string will still split its row. If your data has them, run it through the jq filter above.
If the table is going into a web page or an email rather than a README, the JSON to HTML table converter is the better fit, since HTML cells can hold line breaks and nested lists without any escaping. For a spreadsheet, convert to CSV. And if the input is a deeply nested API response, look at it in the JSON formatter first and pick the array you actually want, because a table of an object with fifty nested keys is just a long line of JSON strings.
Check the blank cells
Blank means two different things in the table: the key was null, or the key was not there at all. Markdown cannot show the difference, and for a README that is usually fine. For anything someone will make a decision from, like a dependency audit or a test report, write null explicitly in the cell function and leave only truly missing fields blank. The optional vs nullable post explains why that difference tends to matter later.