Repairing a Claude Code Session .jsonl File Too Large to Open at 38 MB
AI chat history is just a plain log file that needs maintenance like any other log. If I keep letting it grow without ever wrapping up, one day it will stop opening. The repair principles apply to any append-only file, not just Claude Code.
I opened an old workspace and found that the session history would not open. The UI just sat there frozen, and trying /resume left me waiting far too long. Only when I checked the file did I realize that a single session I had kept working in for 8 days had grown to 38.61 MB with 11,537 records — the program had to load that entire file into memory and parse every line of JSON before it could display anything. So I sat down and wrote about a hundred lines of Python to shrink it instead of deleting the whole session.
The result was 17.41 MB, a 55% reduction, with every line of both user and assistant messages intact, in the original order, and not a single orphan. I have kept the script permanently because I am very likely to need it again.
This session spanned 8 days and contained 5,218 assistant turns. The reason it grew so large was simple — I kept working in one session without wrapping up between work periods, and every tool call that read a large file, every command that produced long output, and every screenshot was appended to that same file.
Understand the .jsonl File Before Touching It
The session file uses JSON Lines: one line is one JSON object, which is one record. The record types I found fell into two main groups.
Message records (type: "user" or "assistant") contain uuid, parentUuid, timestamp, and message.content[], which can hold several kinds of blocks — text, thinking, tool_use, tool_result, image — and some lines also have a separate toolUseResult at the top level.
The other type is file-history-snapshot, which stores snapshots of file contents for the /rewind feature of a running session. The important point is that it is not part of the conversation's uuid chain.
The transcript is reconstructed by following the chain from parentUuid to uuid in file line order. If I delete a record in the middle of the chain, its child points to a parent that no longer exists, leaving a gap in the conversation or preventing the entire session from loading. Another pair that must stay together is the assistant's tool_use and the user's tool_result, which must always have matching tool_use_id values. Deleting either side causes an unmatched content block error as soon as I resume.
Shrink the contents of records, but never reduce the number of records in the chain — except for record types that are outside the chain entirely, such as file-history-snapshot, which can be discarded whole. This rule is not specific to Claude Code; it applies to any append-only file whose records form a chain.
What Can Be Trimmed and What Cannot
Only these 4 things
- file-history-snapshot Discard the entire record — this is /rewind data for a running session; nobody rewinds a finished session (414 records = 5.67 MB)
- thinking blocks with empty thinking Only an encrypted signature remains, which cannot be read and is not displayed (1,658 blocks = 7.09 MB)
- base64 images Replace with a 1x1 PNG, reducing the payload to roughly a hundred bytes while keeping an image block that conforms to the schema (33 images = 2.78 MB)
- tool_result / toolUseResult Clip at 1,200 characters and append a marker indicating how much was removed
Once lost, it is gone
- message.content[].text On both the user and assistant sides — this is the actual conversation
- uuid / parentUuid / timestamp And the order of lines in the file
- tool_use.input Contains the commands issued, making it the most valuable history after the messages themselves (my script clips this at 1,200 characters too; anyone who wants to keep it in full can adjust this)
- thinking blocks with actual content They are readable and must not be trimmed
Adding those up gives snapshot 5.67 + signature 7.09 + images 2.78 = 15.54 MB, all material nobody goes back to read. The rest is long tool output; the actual conversation makes up a very small portion of the file.
The Repair Script
I keep it at ~/.claude/tools/repair_session_jsonl.py. Here is the full script.
"""Slim a bloated Claude Code session .jsonl so it opens again.
KEEPS every user/assistant message record, its visible text, tool_use inputs,
uuid/parentUuid chain, timestamps -> the transcript still reads end to end.
SHRINKS the parts that carry no readable value:
1. file-history-snapshot records (only power /rewind on a live session)
2. thinking blocks whose `thinking` text is EMPTY -> dropped whole
(all payload is the opaque encrypted `signature`; nothing to read)
3. base64 image blobs -> 1x1 png placeholder
4. tool_result / toolUseResult -> clipped to CAP chars with a marker
Usage: repair_session_jsonl.py <in.jsonl> <out.jsonl> [--cap N]
"""
import json, sys, io, collections
IN, OUT = sys.argv[1], sys.argv[2]
CAP = int(sys.argv[sys.argv.index("--cap") + 1]) if "--cap" in sys.argv else 1200
PLACEHOLDER = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP"
"4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC")
MARK = "\n[... clipped by session-repair: %d chars ...]"
stats = collections.Counter()
def clip(s):
if isinstance(s, str) and len(s) > CAP:
stats["clipped_chars"] += len(s) - CAP
return s[:CAP] + (MARK % (len(s) - CAP))
return s
def strip_images(o):
if isinstance(o, dict):
if o.get("type") == "image" and isinstance(o.get("source"), dict):
src = o["source"]
d = src.get("data")
if isinstance(d, str) and len(d) > len(PLACEHOLDER):
stats["image_chars"] += len(d) - len(PLACEHOLDER)
stats["images"] += 1
src["data"] = PLACEHOLDER
src["media_type"] = "image/png"
return o
for v in o.values():
strip_images(v)
elif isinstance(o, list):
for v in o:
strip_images(v)
return o
def clip_deep(o):
if isinstance(o, dict):
return {k: clip_deep(v) for k, v in o.items()}
if isinstance(o, list):
return [clip_deep(v) for v in o]
return clip(o)
kept = dropped_snap = dropped_think = 0
with io.open(IN, encoding="utf-8") as fin, \
io.open(OUT, "w", encoding="utf-8", newline="\n") as fout:
for line in fin:
if not line.strip():
continue
try:
o = json.loads(line)
except Exception:
stats["unparsed_passthrough"] += 1
fout.write(line if line.endswith("\n") else line + "\n")
kept += 1
continue
if o.get("type") == "file-history-snapshot":
dropped_snap += 1
stats["snapshot_chars"] += len(line)
continue
msg = o.get("message")
if isinstance(msg, dict) and isinstance(msg.get("content"), list):
new = []
for b in msg["content"]:
if (isinstance(b, dict) and b.get("type") == "thinking"
and not (b.get("thinking") or "").strip()):
dropped_think += 1
stats["signature_chars"] += len(b.get("signature") or "")
continue
# clip only tool payloads; leave prose untouched
if isinstance(b, dict) and b.get("type") in ("tool_result", "tool_use"):
b = clip_deep(b)
new.append(b)
# never leave an assistant turn with an empty content array
msg["content"] = new if new else [{"type": "text", "text": ""}]
strip_images(o.get("message"))
strip_images(o.get("attachment"))
if "toolUseResult" in o:
o["toolUseResult"] = clip_deep(strip_images(o["toolUseResult"]))
fout.write(json.dumps(o, ensure_ascii=False) + "\n")
kept += 1
print("kept %d lines | dropped %d snapshots, %d empty thinking blocks"
% (kept, dropped_snap, dropped_think))
for k, v in sorted(stats.items()):
extra = " (%.2f MB)" % (v / 1048576) if k.endswith("chars") else ""
print(" %-20s %10d%s" % (k, v, extra))
Lines Worth a Close Look
The script streams the file line by line instead of loading the whole thing into RAM, so even a 40 MB file uses very little memory. But the details that really matter are these small ones.
except Exception: passthrough — I write any line that fails JSON parsing back unchanged instead of discarding it. Better to be safe than to assume it does not matter.
msg["content"] = new if new else [{"type":"text","text":""}] — an assistant turn with an empty content array violates the schema. It causes a problem as soon as I resume and send it back to the API, so I always insert an empty text block instead.
clip_deep is called only on tool_result and tool_use blocks and the toolUseResult key. It leaves text blocks untouched — this is crucial. If I accidentally clip every string longer than CAP across the whole file, long user messages and long answers get cut too, needlessly losing useful content.
ensure_ascii=False is essential; otherwise, Thai becomes \uXXXX and the file grows back to nearly its original size. And newline="\n" prevents CRLF line endings when writing on Windows.
strip_images walks recursively through both message and attachment because pasted images and screenshots live in different places. The placeholder is a real 1x1 PNG that can be decoded from base64 and opened, and I must also force media_type: image/png because the original might be jpeg.
The Actual Workflow
- Always close Claude Code first If a process still holds the file open, it will happily overwrite it again
- Back up the full file first. 38 MB is not large; keep it until I have confirmed that the old session actually opens, then delete it
- Run the script to produce a new file without overwriting the original
- Check before swapping — the uuid count must stay the same, and orphan must be 0
- Only after the checks pass, replace the original with the repaired file and reopen the program
# 1. backup ตัวเต็ม
cp ~/.claude/projects/<workspace>/<session-uuid>.jsonl \
~/.claude/backups/<session-uuid>.jsonl.$(date +%Y%m%d).bak
# 2. ซ่อมออกเป็นไฟล์ใหม่
python repair_session_jsonl.py <session-uuid>.jsonl <session-uuid>.fixed.jsonl --cap 1200
# 3. เทียบขนาดก่อนสลับ
ls -lh <session-uuid>.jsonl <session-uuid>.fixed.jsonl
My check before swapping is just this short snippet. It counts uuid values across the file, then checks whether any parentUuid points to a uuid that does not exist.
import json, sys
uu, orphan = set(), 0
rows = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
for o in rows:
if o.get("uuid"): uu.add(o["uuid"])
for o in rows:
p = o.get("parentUuid")
if p and p not in uu: orphan += 1
print(len(rows), "records |", len(uu), "uuid |", orphan, "orphan")
Run it on the original and repaired files and compare the results. The required result is 8,171 uuid values in both files, and orphan = 0. If the uuid counts differ or even a single orphan appears, do not overwrite the original under any circumstances. Go back and review the trimming conditions.
1) Removing file-history-snapshot permanently removes /rewind for that session. This is acceptable if the session has ended, but never do it to a session with work still in progress · 2) Never remove thinking blocks with actual content. The condition must be strictly "thinking is empty after strip". Using only type == thinking as the condition will remove all readable content too · 3) Never delete a record containing tool_use without deleting its matching tool_result, or vice versa — resuming will definitely fail
Preventing It from Growing Again
My conclusion is that the real problem is not the file size, but my habit of keeping a whole week's work in one session. In this case, that meant 8 days and 5,218 assistant turns in a single file.
I have switched to separate sessions for separate tasks, saving a summary to memory before finishing each work period. Next time, I open a new session and continue from that summary. This gives me two benefits: smaller files and more concise context, because an overly long session does not just create a large file; it carries irrelevant old material along throughout the work.
Beyond that, I watch for actions that generate large payloads each time — reading entire large files, commands that produce long output, and taking screenshots in quick succession (just 33 images already totaled 2.78 MB). I also make a habit of checking the size occasionally. Once a session file exceeds around 15-20 MB, it is time to start thinking about wrapping up.
ls -S ~/.claude/projects/*/*.jsonl | head -5 | xargs ls -lh
Feel free to set --cap higher than 1,200 to retain more tool output. I chose this number because I think hardly anyone goes back to read beyond that anyway. But there is one principle I really want readers to take away and use — shrink the contents of records, but do not reduce the number of records in the chain. If I have left anything out or made a typo, I will come back and fix it later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first