references/cache-format.md — Chat Cache Exporter
From version 0.1.2 of Chat Cache Exporter, shown publicly by its creator. 8.7 KiB · SHA-256 01754faabe1b5a84e17e94e3620918c82fd7f3c7a505ac895a2c49f4829eb954.
# Discord desktop cache format and export reference
This reference describes the narrow cache evidence accepted by
`scripts/discord_cache_export.py` and the files it produces. It does not define
the complete Chromium cache format.
## Cache boundary
The CLI reads only regular files directly inside the resolved `Cache_Data`
directory. It does not recurse, follow symlinks, read Local Storage, inspect an
account token, or contact the network.
`--channel` filters summaries and exports after the whole selected cache has
been scanned and parsed; it does not restrict cache reads to selected channels.
Cache entries interleave metadata and body bytes. The tool recognizes an entry
only when its bytes contain a stored request URL matching this shape:
```text
https://discord.com/api/v<number>/channels/<channel_id>/messages
```
Query strings may follow the path. A URL for another endpoint is not a match.
The matched channel identifier in the request URL identifies the response, but
export grouping uses each valid message object's cached `channel_id` value.
## Embedded streams
The response body can appear after unrelated cache metadata, so the tool scans
the complete entry for compression magic bytes and attempts every candidate
stream.
- Gzip begins with bytes `1f 8b 08` and is decoded with `zlib`.
- Zstd begins with bytes `28 b5 2f fd` and is decoded with the standard
library `compression.zstd`, which needs Python 3.14 or newer.
On Python 3.10 to 3.13, zstd candidates are counted in `zstd_skipped`. A zstd
candidate that fails on Python 3.14 or newer is counted in
`unreadable`. Files that cannot be opened are also counted in `unreadable`.
Each successfully decompressed stream must decode as JSON and its root must be
an array. A compressed candidate with a non-JSON body or JSON object is counted
in `unreadable`. Every array item must be a message object: message and channel
identifiers must be strings containing only ASCII decimal digits, and author
data must be an object. An item that is not an object is nonconforming. A candidate with
a nonconforming message is counted as unreadable so it cannot abort recovery of
valid data from other cache entries.
An individual cache file is limited to 128 MiB and each decompressed candidate
is limited to 64 MiB. Exceeding either bound counts as `unreadable`. The same
bounds apply to `scan`, `export`, and export dry runs.
## Deduplication and order
Messages are deduplicated by the string value of `id` across every matched
entry and decompressed stream. Each channel is sorted by the numeric value of
the Discord snowflake identifier, which gives chronological order for valid
message identifiers.
The displayed author name uses this fallback order:
1. `author.global_name` when nonempty.
2. `author.username` when nonempty.
3. `author.id`.
## Summary fields
For each channel, the table reports:
- channel identifier;
- number of distinct recovered messages;
- number of distinct author identifiers;
- first and last timestamp dates; and
- up to three most common display names.
A date is the first 10 characters of the cached timestamp when they have the
form `YYYY-MM-DD` with ASCII digits; otherwise it is reported as `????-??-??`.
In the plain-text table, display names and every other cell have tabs, line
breaks, and other control and format characters written as the same visible
escapes used in transcripts, so each channel occupies exactly one line.
Run-level counts are:
- `scanned`: regular cache files opened or attempted directly in the cache
directory.
- `matched`: entries containing a matching stored message request URL.
- `unreadable`: files that could not be read, compressed candidates that
failed to decode, decoded candidates that were not JSON arrays, or arrays
containing a nonconforming item.
- `zstd_skipped`: zstd candidates skipped because the running Python has no
`compression.zstd` (Python 3.10 to 3.13).
- `messages`: distinct retained message identifiers after deduplication and
channel selection.
- `channels`: distinct retained channel identifiers after selection.
## JSON export
`OUT_DIR/messages.json` has this top-level shape:
```json
{
"tool": "discord-cache-export",
"version": "0.1.2",
"cache_dir": "/resolved/cache/path",
"scanned": 12,
"matched": 2,
"messages": 3,
"incomplete": false,
"skipped": {
"unreadable": 0,
"zstd_skipped": 0
},
"channels": {
"123456789012345678": [
{
"id": "123456789012345679",
"channel_id": "123456789012345678",
"timestamp": "2024-01-02T03:04:05.000000+00:00",
"content": "Synthetic example",
"author": {
"id": "123456789012345680",
"username": "user_a"
}
}
]
}
}
```
The message objects under `channels` are retained from the cached response.
They can contain additional fields. The example values are synthetic.
## Transcript format
Each selected channel produces
`OUT_DIR/transcripts/<channel_id>.txt`. The `transcripts/` directory is
created only when at least one channel is exported, so an export with no
recovered messages writes only `messages.json`. Each message starts with exactly one
header line, followed by zero or more continuation lines:
```text
YYYY-MM-DDTHH:MM:SS display_name: first content line
| next content line
| last content line [attachment: name] [embed: url]
```
The header timestamp is the first 19 characters of the cached timestamp when
they have the form `YYYY-MM-DDTHH:MM:SS` with ASCII digits; otherwise it is
written as `????-??-??T??:??:??`. A header line therefore always starts with a
digit or `?`, never with a space.
Content is split with Python `str.splitlines` semantics, so `\n`, `\r`,
`\r\n`, vertical tab, form feed, `\x1c`, `\x1d`, `\x1e`, `\x85`, U+2028, and
U+2029 all start a new line. The first content line follows the header; each
further line is prefixed with four spaces and `| `. Other control and format
characters (Unicode categories C, Zl, and Zp, including tab) in content, display
names, attachment names, and embed URLs are written as visible `\uXXXX`
escapes (`\UXXXXXXXX`, eight hex digits, for code points above U+FFFF),
so none of those fields can break a line or imitate a header.
Each attachment adds its cached `filename` after the last content line; a
missing filename or a non-object attachment is rendered as `?`. Each embed with
a nonempty `url` adds that URL. Multiple attachments and embeds append multiple
bracketed fields.
The output directory must be new or empty. The tool rejects a nonempty
directory instead of leaving stale transcripts from an earlier, broader
export. Its parent must already exist; no directories above `--out` are created.
A dry-run previews and may create `--out` itself, then creates only the
approval state inside it. The approval digest binds the fully resolved
destination, destination and existing parent directory identities (`st_dev`,
`st_ino`), rendered file paths, sizes and
content hashes, channel identifiers, and message identifier sets. A live export
refuses to write anything, and exits with status `1`, when the approval state
is missing or when its fresh scan produces a different digest. Without approval
state it does not even create the output directory.
Live export walks supplied output directory components with directory
descriptors, refuses symlinks, verifies approved identities, and creates files
relative to the verified descriptor with `O_CREAT|O_EXCL|O_NOFOLLOW`.
A parent replaced with a symlink after approval is refused before any live
write. Export (including dry-run) supports macOS and Linux with `os.O_NOFOLLOW`,
`os.O_DIRECTORY`, and `os.open` in `os.supports_dir_fd`; unsupported platforms,
including Windows, are refused before any export write. Scan defaults support
macOS, Windows, and Linux.
Dry-run also refuses symlink components and writes its approval state relative
to the verified directory descriptor.
## Completeness and exit status
The cache is an opportunistic snapshot. Cache eviction, the user's browsing
history, unsupported response compression, unreadable files, messages deleted
before they were cached, and API changes can all reduce coverage.
The snapshot can also be stale. A message deleted or edited in Discord after it
was cached can still appear, in its cached form, in the export. The tool has no
way to detect this, because it reads only the local cache and never contacts
Discord.
If `unreadable` or `zstd_skipped` is nonzero, the result has
`"incomplete": true`, stderr identifies it as partial, and the exit status is
`2`. With `--allow-partial`, the same incomplete result exits with status `0`.
Zero matching entries is an error with status `1`.
For complete history, use Discord Settings, Data & Privacy, Request all of my
Data.
By Zero Width Lab (zerowidthlab.com)
License: MIT (read it).
MIT
MIT LicenseCopyright (c) 2026 Zero Width Lab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.