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:

FileWhat it holds
conversations.jsonEvery chat. This is the one you want.
chat.htmlA basic offline viewer. Fine for small exports, slow for big ones.
user.json, message_feedback.json, shared_conversations.jsonAccount 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

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