ChatGPT export: convert conversations.json to Markdown
Short answer: request your data export, unzip it, and run the script below on conversations.json. You get one .md file per chat, named by date and title. If you would rather not run code, Chat Archive Viewer opens the ZIP directly and saves any chat as Markdown.
1. Get the export
In ChatGPT, open Settings, then Data controls, then Export data, and confirm. OpenAI's help article says the export arrives by email or SMS, can take up to 7 days, and the download link expires 24 hours after you receive it. Download it while you are signed in to the same account.
2. What is inside the ZIP
OpenAI does not publish the file format. The best public map is the open-source convoviz project's spec (version 3.0, February 2026). The files you will usually see:
| File | What it holds |
|---|---|
conversations.json | Every chat. This is the one you want. |
chat.html | A basic offline viewer. Fine for small exports, slow for big ones. |
user.json, message_feedback.json, shared_conversations.json | Account details, thumbs up/down, shared links. |
dalle-generations/, file-* | Generated images and your uploads. |
Some exports split the chats into conversations-000.json, conversations-001.json and so on. Run the script on each file.
3. Why it is not a simple list
Each chat in conversations.json stores its messages as a tree in a field called mapping. Every message points to its parent. When you edit a prompt or regenerate a reply, ChatGPT keeps the old version as a sibling branch. A field called current_node points at the last message of the branch you were looking at.
So if you dump every message in mapping, you get every discarded draft too, out of order. The right way is to start at current_node, walk up through the parent links, and reverse the list. That is exactly what the script does.
4. The script
Python 3, no extra packages. Save it as export_to_markdown.py (download) and run python export_to_markdown.py conversations.json markdown.
import datetime, json, pathlib, re, sys
src, out = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
out.mkdir(parents=True, exist_ok=True)
data = json.loads(src.read_text(encoding="utf-8"))
if isinstance(data, dict): # some exports wrap the list
data = data.get("conversations", [])
def kept_branch(conv):
mapping, node, msgs = conv.get("mapping", {}), conv.get("current_node"), []
while node in mapping:
msg = mapping[node].get("message")
if msg:
msgs.append(msg)
node = mapping[node].get("parent")
return list(reversed(msgs))
used = set()
for conv in data:
title = (conv.get("title") or "").strip() or "Untitled chat"
lines = [f"# {title}", ""]
for msg in kept_branch(conv):
role = msg.get("author", {}).get("role")
if msg.get("metadata", {}).get("is_visually_hidden_from_conversation"):
continue
if role == "assistant" and msg.get("recipient", "all") != "all":
continue # internal tool call
parts = (msg.get("content") or {}).get("parts") or []
text = "\n\n".join(p for p in parts if isinstance(p, str)).strip()
if role in ("user", "assistant") and text:
lines += [f"## {'You' if role == 'user' else 'ChatGPT'}", "", text, ""]
day = datetime.datetime.fromtimestamp(conv.get("create_time") or 0).strftime("%Y-%m-%d")
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', " ", title)[:80].strip() or "Untitled chat"
path = out / f"{day} {name}.md"
n = 2
while path.name.lower() in used:
path = out / f"{day} {name} ({n}).md"
n += 1
used.add(path.name.lower())
path.write_text("\n".join(lines), encoding="utf-8")
print(f"wrote {len(used)} files to {out}")
We ran this on a synthetic test export with edited prompts, regenerated replies, untitled chats and mixed-language titles. It kept only the chosen branch and gave each untitled chat its own file.
What the script leaves out
- Images and file uploads (they show up as non-text parts, which it skips).
- Code Interpreter runs and tool output.
- Chats whose
current_nodeis missing come out with only a title. - It loads the whole file into memory. On a 100 MB synthetic export it peaked at about 800 MB of RAM. For bigger files, use the streaming version in this guide.
5. The no-code way
Chat Archive Viewer is a Chrome extension that opens the export ZIP as is. It follows the kept branch, shows tool calls, attachments and image prompts instead of dropping them, and has a "Save as Markdown" button on every chat. Free covers your newest 100 chats. Pro ($12 once) exports every chat to a Markdown zip or an Obsidian vault with front matter.
It asks for no website permissions, so it cannot read your ChatGPT account. It only reads the file you hand it.
FAQ
Is converting my export against OpenAI's rules?
You are reading a file OpenAI gave you through its own export button. Nothing here logs in, calls ChatGPT, or scrapes the site.
Are my custom instructions in the export?
They can appear as a system message marked as user-written. The script skips system messages; Chat Archive Viewer shows them as "Custom instructions".
Can I import the Markdown into Obsidian?
Yes, drop the folder into your vault. For front matter (dates, model, tags) and a month index, use the Obsidian export in Chat Archive Viewer Pro.
Sources
- OpenAI Help Center, "Exporting your ChatGPT history and data": help.openai.com/en/articles/7260999
- convoviz, ChatGPT export specification v3.0: github.com/mohamed-chs/convoviz