August 30, 202610 min

ChatGPT Export Timestamps: create_time and update_time

You requested your data from ChatGPT, unzipped the archive, opened conversations.json, and found numbers where you expected dates. Every conversation carries a create_time and an update_time that look like 1745239481.86942 — no timezone, no format, no explanation.

They are Unix timestamps, and once converted they are the single most useful field in the export. They are what lets you find the week you worked through a decision, reconstruct the order in which a project actually developed, or select a date range to archive rather than dumping the whole file somewhere and never opening it again.

They also have three behaviours that catch people out: they are seconds rather than milliseconds, they carry a fractional part that some parsers reject, and they are frequently null at the message level. This post covers where the timestamps live, how to convert them correctly in the three tools people actually use, and what they are good for once converted. For the wider structure of the file, start with How to Read and Use Your ChatGPT conversations.json.

Where the Timestamps Live

There are two levels, and conflating them is the root of most confusion.

At the conversation level. The file is a JSON array. Each element is one conversation, with title, create_time, update_time, mapping, and current_node among its fields. The two timestamps here describe the conversation as a whole.

At the message level. Inside mapping — an object keyed by node UUID — each node has an optional message. When a message is present it carries its own create_time, describing that individual turn.

So a conversation with forty turns has two conversation-level timestamps and up to forty message-level ones. If you want "when did this chat happen", use the conversation level. If you want "how long did this session run" or "when exactly did I say that", you have to walk into the mapping.

They Are Unix Epoch Seconds, Not Milliseconds

1745239481.86942 is seconds since 1970-01-01 UTC, with a fractional part for sub-second precision.

This matters because JavaScript's Date constructor expects milliseconds. Passing the raw value produces a date in January 1970, which is an obvious enough error that people catch it, and multiplying by 1000 fixes it:

new Date(1745239481.86942 * 1000)

Python's datetime goes the other way and expects seconds, so the raw value works directly there. The fractional part is accepted and preserved:

from datetime import datetime, timezone
datetime.fromtimestamp(1745239481.86942, tz=timezone.utc)

Note the explicit tz argument. datetime.fromtimestamp() without it returns naive local time, which is usually what you want for reading but is a hazard the moment you compare values across machines or write results to a file someone else will read. Be deliberate about which one you are producing.

Spreadsheets are the third common destination and the one that needs the most care. A Unix timestamp pasted into a spreadsheet cell is just a large number; converting it requires an explicit formula that divides by 86400 and adds the epoch offset, and the result then needs date formatting applied. It works, but it is fiddly enough that converting before import is usually less painful than converting after.

Conversation create_time vs update_time

create_time is when the conversation started. update_time is later, and describes the most recent change to the conversation.

The distinction that matters in practice: do not assume update_time equals the timestamp of the last message. It is the last time the conversation record changed, and a conversation record can change for reasons that are not a new message — renaming it, archiving it, and similar bookkeeping all plausibly touch it. If your analysis depends on "when did the last message in this thread arrive", derive that from the mapping rather than trusting update_time to be a proxy for it.

For most purposes the distinction is academic and update_time is a perfectly good sort key. It becomes important in exactly one common case: measuring how long a conversation stayed active. The gap between create_time and update_time is not session duration. It is the span between the first message and the last modification, which for a thread you returned to three weeks later is three weeks, regardless of the fact that you spent forty minutes in it total.

Message-Level create_time Inside mapping

To get per-message timestamps you iterate the mapping's values, skip nodes without a message, and read create_time from each one.

import json

with open('conversations.json') as f:
    conversations = json.load(f)

conv = conversations[0]
times = []
for node in conv['mapping'].values():
    msg = node.get('message')
    if not msg:
        continue
    ts = msg.get('create_time')
    if ts is None:
        continue
    times.append(ts)

times.sort()
print(len(times), 'timestamped messages')

Two guards in that loop are doing real work, and both correspond to things that are genuinely present in exports.

The first is if not msg. The mapping is a tree, and the root node — plus any structural nodes — has message set to null. Iterating without that check throws on the first one.

The second is if ts is None, which deserves its own section.

Nulls Are Normal

A message-level create_time of null is not corruption. It shows up on system messages and other nodes that were never a visible turn, and any code that assumes the field is always a number will crash partway through a large export — usually after several minutes of processing, which is the worst time to discover it.

Write the null check in from the start. In Python that is if ts is None: continue. In jq, use the // alternative operator or filter explicitly:

jq '[.[] | .mapping[] | .message? | select(. != null) | .create_time | select(. != null)] | length' conversations.json

Conversation-level create_time is more consistently populated than the message-level field, which is one reason to prefer it when you only need approximate dating. But defensive code costs one line, and a full export is large enough that you do not want to find out the hard way.

Sorting and Filtering by Date

This is what the timestamps are for. The pattern is: convert your target dates to epoch seconds once, then compare numerically.

Find everything created in a given window:

jq '.[] | select(.create_time >= 1740787200 and .create_time < 1743465600) | .title' conversations.json

List conversations newest-first with readable dates:

jq -r 'sort_by(.update_time) | reverse | .[] | [(.update_time | todate), .title] | @tsv' conversations.json

todate is the useful piece there — jq converts epoch seconds to an ISO 8601 string in UTC, which sorts lexically and is unambiguous to read. It truncates the fractional part, which is fine for anything at day or minute resolution.

The equivalent grouping in Python is a one-liner over the parsed list, and the useful move is usually to bucket by month rather than list individual conversations, because the shape of your usage over time is more informative than any single entry. A month with sixty conversations and a month with four tell you something about where your context actually accumulated.

Gotchas That Cost People an Afternoon

Sorting the file as strings. The array is not sorted by date in any guaranteed way, and sorting the JSON text does nothing useful. Sort by the numeric field.

Assuming local time. Epoch is UTC by definition. Whether you see local time depends entirely on the conversion function you chose. Two people running the same script in different timezones get different output from the same file, and neither is wrong.

Filtering on update_time when you meant create_time. A conversation started in March and touched in June will appear in a June filter on update_time and a March filter on create_time. Pick the one that matches the question you are asking.

Expecting message order to match mapping order. Object key order in the mapping is not chronological order. If you need turns in sequence, either sort by create_time after collecting them or walk the tree from the root down through current_node.

Treating the export as a live view. The file is a snapshot from the moment the export was generated. Timestamps in it stop where the export did, and a new export is a new file rather than an update. What the export does and does not include is covered in What to Do With Your ChatGPT Data Export.

What the Timestamps Are Actually Good For

Three uses justify the effort of getting the conversion right.

Finding the conversation you half-remember. You know roughly when it happened and roughly what it was about. A date-range filter narrows thousands of conversations to a handful, and then title search over that handful works. Searching titles alone across the whole file usually does not, because titles are auto-generated and frequently unhelpful.

Reconstructing sequence. When a project developed over months, the order in which conclusions were reached is often more informative than the conclusions themselves. Sorting by create_time reconstructs that order, including the approaches you tried and abandoned — which are exactly the things you forget and then re-try a year later.

Deciding what to keep. A full export is unwieldy and mostly noise. Timestamps let you select the periods that matter — the months you were working on something real — and treat the rest as archive. That selection step is what turns an export from a file you feel obliged to keep into something you actually use. If archiving is your goal rather than analysis, How to Back Up Your AI Conversations covers the storage side.

From Timeline to Memory

There is a limit to what any amount of timestamp work buys you, and it is worth naming.

A sorted, filtered, date-labelled export is still a transcript archive. It tells you when things happened and lets you go read them. What it does not do is make any of that context available to an assistant in your next conversation, which is usually the actual goal. Nobody parses create_time for its own sake — they do it because they are trying to recover something they worked out months ago and now need again.

Getting from archive to usable context means compressing transcripts into something short enough to hand a model: the decisions, the constraints, the state as it stands now, without the false starts. That is what MindLock's distillation does. Worth being precise about the input, though — MindLock imports conversations you save as HTML pages with Ctrl/Cmd+S from ChatGPT, Claude, Gemini, or Perplexity, not the conversations.json bundle. The two are complementary: the export is your archive of record, the saved pages are what feeds memory documents. The import flow is in Importing Conversations.

The practical reading of that split is that timestamp analysis is best used as a selection tool. Work out from the export which periods and threads actually mattered, then bring those specific conversations into a memory layer rather than trying to process the whole archive.

Estimating Session Length Properly

Because the create_time to update_time gap is unreliable as a duration, the honest way to measure how long you actually spent in a conversation is to collect message-level timestamps and look at the gaps between consecutive turns.

Sort the timestamps, take the differences between neighbours, and discard any gap larger than some threshold — thirty minutes is a reasonable default. What remains is time you were plausibly present. Summing those gaps gives a defensible estimate of session length, and counting the discarded gaps tells you how many separate sittings the conversation spanned.

This is more useful than it first sounds. A thread with one continuous ninety-minute session is a different kind of artefact from one with twelve two-minute visits across a month, even when both have the same message count and the same date range. The first is a working session with a conclusion somewhere in it. The second is a reference thread you kept returning to, and the valuable part is probably distributed rather than at the end.

The threshold is arbitrary and you should pick it deliberately rather than inheriting mine. For quick exchanges a five-minute cutoff separates sittings better; for long research threads with pauses for reading, thirty is too aggressive. Run it both ways on a conversation you remember well and see which numbers match your recollection.

Where to Go From Here

If you just need dates on screen, the conversion is one line and the two things to get right are seconds-not-milliseconds and an explicit timezone. If you are writing anything that iterates the mapping, add the null guards before you run it against a large file rather than after.

And if the reason you opened the export was to recover context rather than to analyse it, the timestamps are step one of three: filter to the periods that matter, pull those conversations into a memory layer, and generate context from there. The whole backup-and-reuse workflow is in ChatGPT Export: Advanced Backup Workflow.