Convert ChatGPT JSON to PDF, Word or Markdown (2026)

When you export your data from ChatGPT, you get a \conversations.json\ file. This file contains every conversation you've ever had, but it's structured in a way that's nearly impossible to read without tools. Let's break down exactly what's in this file and how to convert it. The fast answer If you just want readable f…

When you export your data from ChatGPT, you get a \conversations.json\ file. This file contains every conversation you've ever had, but it's structured in a way that's nearly impossible to read without tools. Let's break down exactly what's in this file and how to convert it.

The fast answer

If you just want readable files:

  1. Open the ChatExports converter and drop in your \conversations.json\ (or the whole export ZIP).
  2. Browse or search your conversations and pick the ones you need.
  3. Download them as Word (.docx), PDF, or Markdown — single chats or the whole archive at once.

Everything runs in your browser tab, so the export never leaves your computer. The same flow works for Claude, Gemini, Perplexity, Poe, Grok, Copilot, NotebookLM, and Google AI Studio exports.

Just want to read it? Use a ChatGPT JSON viewer

If you don't need files yet, open the ChatGPT JSON viewer. It reconstructs each thread in the order you actually had it, lists every chat by title and date, and lets you search across all of them — no export, no upload, no account.

Which format should you pick?

The rest of this guide explains what is actually inside the JSON, in case you want to parse it yourself.

Understanding the ChatGPT JSON Structure

The \conversations.json\ file is an array of conversation objects. Each conversation contains:

Why It's a Tree, Not a List

ChatGPT conversations aren't simple back-and-forth exchanges. They can have:

This means the messages are stored as a tree graph with parent-child relationships, not a flat list. To reconstruct the conversation in order, you need to:

  1. Find the root node (the one with no parent)
  2. Follow the children chain from root to leaves
  3. Choose the correct branch at each fork
  4. Filter out system messages and empty nodes
  5. Handle special content types (images, code blocks, tool results)

Field-by-field reference

Every entry in \mapping\ is a node with this shape:

\\\`json

{

"<node-uuid>": {

"id": "<node-uuid>",

"parent": "<parent-uuid> | null",

"children": ["<child-uuid>", "..."],

"message": {

"id": "<message-uuid>",

"author": { "role": "user | assistant | system | tool", "name": null, "metadata": {} },

"create_time": 1704067200.123,

"content": { "content_type": "text", "parts": ["..."] },

"status": "finished_successfully",

"end_turn": true,

"weight": 1.0,

"recipient": "all",

"metadata": { "is_visually_hidden_from_conversation": false }

}

}

}

\\\`

Things worth knowing before you write a parser:

content_type values you will actually hit

\content.parts\ is only present for plain text. Other content types put their payload in different keys, which is the single most common reason home-made scripts silently produce empty documents:

| \content_type\ | Where the text lives | Keep it? |

|---|---|---|

| \text\ | \parts[]\ (strings) | Yes |

| \multimodal_text\ | \parts[]\ of mixed strings and objects (\{"asset_pointer": ...}\ for images) | Text parts yes, image pointers are dead links |

| \code\ | \content.text\ | Usually yes, as a code block |

| \execution_output\ | \content.text\ | Yes, if you want Code Interpreter results |

| \tether_browsing_display\ / \tether_quote\ | \content.result\ / \content.text\ | Usually no |

| \thoughts\ / \reasoning_recap\ | \content.thoughts[]\ (\summary\ + \content\) | Optional — reasoning-model traces |

| \user_editable_context\ | \content.user_profile\ / \user_instructions\ | No — these are your custom instructions |

| \model_editable_context\ | \content.model_set_context\ | No — memory entries |

So a correct text extractor is not \parts[0]\. It is: check \parts\ (and flatten objects inside it), then \text\, then \result\, then \thoughts\, then give up and return an empty string.

Branching: picking the right leaf

When you edit a prompt and regenerate, ChatGPT forks the tree. A node's \children\ array holds every version in creation order, so the last child is the most recent branch — that is what the ChatGPT UI shows you by default. Walking \children[0]\ gives you the abandoned first drafts instead.

Two failure modes to guard against:

  1. Cycles. Corrupted exports occasionally contain a node that is its own ancestor. Keep a \visited\ set or your walk hangs.
  2. Detached subtrees. Some accounts (especially after archive/restore) export conversations where the main thread is empty. The reliable fallback is to ignore the tree entirely, collect every node with a valid message, and sort by \create_time\.

Timestamps and ordering

\create_time\ and \update_time\ are float Unix seconds, not milliseconds — multiply by 1000 before \new Date()\, or you get 1970. They are also nullable on system-generated nodes, so never sort on them without a fallback. The top-level \update_time\ is what ChatGPT's sidebar sorts by, which is why conversation order in your export rarely matches creation order.

The format has changed more than once

Exports from 2023 use a flat \messages\ array on some conversations. Exports from mid-2025 onward added \thoughts\ content for reasoning models and moved some attachments to \metadata.attachments\. A parser written against a single export usually breaks on the next one — which is why the answer to "why does my old script return no conversations?" is nearly always a new \content_type\.

The Manual Approach (Not Recommended)

You *could* write a script to parse the JSON yourself. Here's a simplified example:

\\\`python

import json

with open('conversations.json', 'r') as f:

conversations = json.load(f)

for conv in conversations:

print(f"=== {conv['title']} ===")

mapping = conv['mapping']

Find the root node

root = None

for node_id, node in mapping.items():

if node.get('parent') is None:

root = node_id

break

Walk the tree

current = root

while current:

node = mapping[current]

msg = node.get('message')

if msg and msg.get('content', {}).get('parts'):

role = msg['author']['role']

text = msg['content']['parts'][0]

if isinstance(text, str) and text.strip():

print(f"[{role}]: {text[:100]}...")

children = node.get('children', [])

current = children[-1] if children else None

\\\`

This basic script misses a lot:

The Easy Approach: Use ChatExports

ChatExports handles all of this automatically:

  1. Upload your \conversations.json\ or the full ZIP file
  2. Preview each conversation with proper formatting
  3. Export to Word, PDF, or Markdown. individually or in bulk

What ChatExports Handles That Scripts Don't

Claude and Gemini Exports Are Different

If you also use Claude or Gemini, their export formats are completely different:

ChatExports handles all three formats automatically. Upload any of them and it detects the format.

FAQ

Can I just use the chat.html file in the ZIP?

OpenAI includes a basic HTML viewer, but it's extremely limited. It doesn't support search, export, or any formatting. Most people find it barely usable.

Will this work with ChatGPT Plus, Teams, or Enterprise exports?

Yes. The JSON format is the same across all ChatGPT plans.

How big can the JSON file be?

ChatExports processes everything in your browser, so it depends on your device's memory. Most exports (even with thousands of conversations) work fine on modern devices.

Why does my export show "no conversations found"?

Almost always a format change: a \content_type\ your script does not know about, or a conversation using a flat \messages\ array instead of \mapping\. A parser that falls back through \parts\ → \text\ → \result\ → \thoughts\ handles both.

Can I get one row per message instead of a document?

Yes — that is a CSV export. Each message becomes a row with conversation title, role, timestamp, and text, which is what you want for analysis in Excel, Sheets, or pandas.

What about images and file attachments?

Images generated by DALL-E are referenced by URL in the JSON. Text content from Code Interpreter and file uploads is included inline. ChatExports preserves the text content; image URLs may expire over time.

Convert your ChatGPT JSON now →

ChatGPT export tools