I asked Claude Code to read a 100,000-line access log and pull out every line containing a particular checkout keyword. The count that came back was an order of magnitude off from what grep -c reported on the same file.
The cause was mundane. The Read tool had quietly stopped at the first 2000 lines. No error. No warning. Claude was reasoning over the head of the file, treating it as the whole thing. Working solo on my own apps, there is nobody to catch that discrepancy for me — without the habit of cross-checking counts, I can spend an afternoon building on a wrong number.
Silent truncation like this is a class of bug that hits anyone working with logs, CSVs, large JSON, or minified bundles in Claude Code. After getting bitten by it a few times I built a mental model for when Read is the right tool, when offset and limit come in, and when the answer is "do not use Read at all, drop into bash." Here is what I have settled on.
Why Read Caps at 2000 Lines
Read returns at most the first 2000 lines by default. The cap exists to protect Claude's context window, and the failure mode is to return a partial file without raising an error. When the information you need lives below line 2000, Claude has no signal that anything is missing — it confidently reasons over the head of the file as if it were the whole thing.
What makes this hard is that truncation does not produce a wrong-looking answer. It produces a plausible one. If a tool claims to have found something that does not exist, you get suspicious. If it says "not found," you tend to believe it. Until you internalize that Read cuts silently and never warns, you will keep attributing the mismatch to something else.
Three Signs You Got Truncated
A few signals tell you truncation is in play.
The first is the response ending exactly at line 2000. If you suspect the file is larger and Claude's last line number is 2000, assume it was cut.
The second is file size above roughly 100 KB. At an average of 50 bytes per line, 2000 lines is about 100 KB. Anything heavier is almost certainly past the cap.
The third is the answer "not found" when you know the data is in there. When Claude reports that a December record does not exist in a file that obviously contains a full year, run wc -l first.
# First sanity check whenever truncation feels possible
wc -l /path/to/large.csv
ls -lh /path/to/large.csvStop Eyeballing It — Let a Script Decide
Those three signals are worth knowing, but checking them by hand every time does not survive a busy week. At some point I started routing every sizeable file through a small script before handing it to Claude Code. It does not read the file for me; it just prints a plan.
#!/usr/bin/env bash
# read-plan.sh — decide how to read a file before handing it to Claude Code
# usage: ./read-plan.sh path/to/file
set -euo pipefail
f="${1:?usage: read-plan.sh <file>}"
[ -f "$f" ] || { echo "NOT_FOUND $f"; exit 1; }
bytes=$(wc -c < "$f" | tr -d ' ')
lines=$(wc -l < "$f" | tr -d ' ')
maxlen=$(awk '{ if (length($0) > m) m = length($0) } END { print m+0 }' "$f")
# Binary check via NUL bytes; the first 4 KB is enough
head_bytes=$(head -c 4096 "$f" | wc -c | tr -d ' ')
strip_bytes=$(head -c 4096 "$f" | LC_ALL=C tr -d '\000' | wc -c | tr -d ' ')
kind=text
[ "$head_bytes" != "$strip_bytes" ] && kind=binary
echo "file=$f bytes=$bytes lines=$lines max_line_len=$maxlen kind=$kind"
if [ "$kind" = binary ]; then
echo "PLAN=BASH_ONLY reason=binary (never Read this)"
elif [ "$maxlen" -gt 500 ]; then
echo "PLAN=BASH_ONLY reason=longest line is ${maxlen} chars (minified or single-line JSON)"
elif [ "$lines" -gt 2000 ]; then
echo "PLAN=READ_RANGED reason=${lines} lines (split with offset/limit, or grep first)"
echo " offset candidates: $(seq 0 2000 $((lines - 1)) | tr '\n' ' ')"
else
echo "PLAN=READ_DIRECT reason=${lines} lines (safe to Read as-is)"
fiThree outcomes, and that is the whole point. READ_DIRECT means hand it over. READ_RANGED means walk it with offset. BASH_ONLY means summarize in bash and give Claude the summary. Not having to re-derive that judgment matters most on the days you are tired.
One practical caveat: wc -l counts newline characters, so a file without a trailing newline reports one line short. That difference only bites near the boundary, around 2000 lines, which is exactly where you want to round toward caution.
How I Use offset and limit
Read accepts two arguments that most people skip past in the docs: offset (the line number to start from) and limit (how many lines to read). Using these well is the difference between "Read keeps cutting things" and "Read does exactly what I want."
Pattern 1: You Know Roughly Where to Look
When you have a sense of the line range — a function definition, a section heading, the timestamp of an error — pass offset and limit directly.
Read(file_path="/path/to/server.log", offset=4500, limit=200)
For logs, run grep -n "ERROR" server.log | head first to surface the line numbers of interest, then point offset at one of them.
Pattern 2: You Cannot See the Target
If you are looking for "every line containing X" or "how many rows match Y", filter in bash before calling Read.
# Narrow the file first, then read what is left
grep -n "checkout" /path/to/access.log > /tmp/checkout-lines.txt
wc -l /tmp/checkout-lines.txtIf the filtered output is under 2000 lines, Read it. If not, narrow further with head -100 or another grep.
Pattern 3: You Want a Whole-File Scan
This is bash territory, not Read territory. Read is built to give Claude context, not to be a data-processing engine. Aggregations and full scans belong in bash, awk, or Python — feed Claude only the summary.
The same instinct shows up in Why Claude Code's Glob and Grep Return Zero Results. Learning to ask whether a search tool found nothing or simply could not see everything is exactly the habit that protects you from silent truncation.
Prove You Reached the End with a Sentinel Line
After splitting a file across several reads, you often want to know whether the last chunk actually landed. Counting line numbers by eye is tedious, and Claude's own "I've read the file" is not evidence of anything.
The trick I use is to append a marker line to a copy of the file.
# Append a sentinel to a temporary copy
cp /path/to/large.csv /tmp/with-sentinel.csv
printf 'EOF_MARKER_%s\n' "$(date +%s)" >> /tmp/with-sentinel.csv
# Keep the expected value handy
tail -1 /tmp/with-sentinel.csvThen ask Claude Code: "when you finish reading, echo back the line starting with EOF_MARKER_." If that line comes back, you reached the bottom. If it does not, either the read was truncated or your offset walk has a gap in it.
It turns "I think it read everything" into "I have proof it did." As a preflight step before handing off an aggregation task, that is a cheap trade. Always append to a copy, never to the original.
CSVs Are Where This Bites Hardest
The App Store Connect reports for my apps run to tens of thousands of rows per month. Letting Claude Code Read one of them directly works fine for the headers and the first few thousand lines, but a query like "what was December's total" can come back wrong because December lives at the bottom of the file, past the cap.
My standard CSV workflow now looks like this:
# 1. Inspect the header
head -1 /tmp/sales-2025.csv
# 2. Filter on the column you care about (rows where revenue >= 1000)
awk -F',' '$5 >= 1000' /tmp/sales-2025.csv > /tmp/filtered.csv
wc -l /tmp/filtered.csv
# 3. Compute the aggregate in bash, hand only the answer to Claude
awk -F',' '{sum+=$5} END {print sum}' /tmp/sales-2025.csvFor CSVs, asking Claude to reason over raw rows is almost always worse than asking Claude to interpret a small numeric summary.
Logs and the "Yesterday's Errors" Trap
A common version of this bug is asking Claude to investigate "yesterday's errors" while Read quietly returns a window of yesterday morning's traffic and stops. The pattern I keep on hand for log triage is:
# Pull error lines plus surrounding context, then take only the recent ones
grep -nB 2 -A 5 "ERROR\|Exception" /var/log/app.log | tail -200-B and -A give you context, tail keeps it recent. The result fits comfortably in the cap. Real-time tailing (tail -f) does not work well as a Claude input — take a fixed snapshot instead, hand it over, and ask for analysis.
JSON Files Need Their Own Approach
Large JSON files are a separate trap. A 5 MB JSON file might be a single line, in which case Read will not "truncate at line 2000" — it will pull the whole thing and burn enormous context. A formatted JSON file, on the other hand, can run to 50,000 lines and get cut.
For JSON I almost never let Claude Read directly. The pattern is to extract just the keys or just the relevant subtree with jq, then feed Claude the shape.
# What is the top-level shape?
jq 'keys' /tmp/big-payload.json
# How many entries are in the items array?
jq '.items | length' /tmp/big-payload.json
# Pull a representative sample of three records
jq '.items[0:3]' /tmp/big-payload.jsonClaude can do excellent reasoning over a three-record sample plus a count and a key list. It struggles when handed 50,000 lines of nested objects.
Do Not Read Binary or Minified Files
Truncation is one issue. Wasting tokens is another. Read assumes text. Pointing it at minified JS bundles (megabytes with almost no line breaks), images, PDFs, or SQLite files burns context and degrades Claude's reasoning.
My personal rule: if a single line is over 500 characters, or the file is larger than 1 MB, do not Read it — summarize from bash instead. For a minified bundle, wc -c plus inspection of package.json or the source map tells you more than dumping the file.
The broader idea — designing around tools that fail quietly rather than loudly — is covered in Running Claude Code Without Breakage: A Failure-First Production Workflow. Truncation is one of the least visible failures in that category.
A Bigger Context Window Does Not Lift the Read Cap
The July 2026 Claude Code update made Opus 5 the default Opus model, with a 1M-token context window. The obvious thought follows: if the context is that wide, can I just hand over the big file?
Not quite. Context size and tool output limits live on different layers. The context window is how much the model can hold at once; the 2000-line cap is a property of the Read tool itself. Moving to a larger model does not change what the tool decided to return.
Where the wider context genuinely helps is downstream. Walking a file with offset across ten chunks and keeping all ten in play used to run into the "it forgot the early chunks" problem. That constraint has eased noticeably.
So the practical stance is unchanged in one direction and improved in the other: still split large files or filter them in bash, but expect to carry far more of the split results through a single session. Default models and pricing shift quickly, so verify against primary sources before you build a workflow on top of them.
My Pre-Read Checklist
This is the routine I run before handing any sizeable file to Claude Code.
Everything goes through read-plan.sh first. READ_DIRECT gets handed over as-is; READ_RANGED gets pre-filtered with grep or awk. Anything that smells like aggregation is finished in bash, with only the summary passed to Claude. When reaching the end of the file actually determines the answer, I append a sentinel line before I start. Since adopting this flow, silent-truncation incidents have basically disappeared.
Learning a tool's limits before relying on it is the same skill as preventing bugs. Bringing Claude Code into a serious workflow is no different — get the Read behavior into your fingers through small experiments first, and everything downstream gets easier.
What to Try Next
Grab a real log or CSV with at least 100,000 lines. Ask Claude Code to call Read(file_path=..., offset=0, limit=100) and then run wc -l in bash. Once Claude can articulate "the file has N lines and the first 100 contain X," using offset and limit deliberately becomes second nature.
Thanks for reading. I hope this saves someone the afternoon I lost to a silently truncated log file.