●2.1.296 — Subagents can set their own autoCompactWindow, and an env var pins the model used by workflow agents●HAIKU 5.5 — Since 2.1.293 (Oct 7), claude-haiku-5-5 is the default Haiku model on the Anthropic API●SONNET 4.5 — Retires on the Claude API on 11/30, 47 days left. Migrate to Sonnet 5.5●Q&A — A write-up shows three places to look when a Mod draws nothing and reports no error●AGENTS.MD — One write-up explains how to split the entry points when Codex and Claude Code share a folder: AGENTS.md and CLAUDE.md●NEW — Approval prompts for "cd, git add, commit" every time: not a missing setting, but how it works●2.1.296 — Subagents can set their own autoCompactWindow, and an env var pins the model used by workflow agents●HAIKU 5.5 — Since 2.1.293 (Oct 7), claude-haiku-5-5 is the default Haiku model on the Anthropic API●SONNET 4.5 — Retires on the Claude API on 11/30, 47 days left. Migrate to Sonnet 5.5●Q&A — A write-up shows three places to look when a Mod draws nothing and reports no error●AGENTS.MD — One write-up explains how to split the entry points when Codex and Claude Code share a folder: AGENTS.md and CLAUDE.md●NEW — Approval prompts for "cd, git add, commit" every time: not a missing setting, but how it works
Grep's First 250 Lines Touched Only 42 of the 183 Files That Matched
Searching an article repository for the word hook returned 1,312 lines across 183 files. ripgrep, git grep and grep all finished in 11 to 35 ms, so speed was never the issue. Output size and ordering were. A script that estimates what a 250-line cutoff will miss, plus a search order backed by measurements.
I was building a table to compare search speed. ripgrep, git grep, and plain grep, each run seven times for the same word in the same folder, with the median recorded.
When the table was done, I stopped. The time column sat between 11 and 35 ms for everything, so there was nothing worth arguing about. What caught my eye was the next column, output bytes. For the same word, the output ranged from 15,207 bytes to 771,629 bytes, roughly a 50x spread.
This is a measurement on the article repository of a website I run as an indie developer: 893 MDX files per language, Japanese and English. When comparing search tools, the thing to look at is not speed but how much comes back and how that amount gets cut off. Here is the record.
Setup
Here is the setup, in case you want to reproduce it.
A Linux work environment (4 cores), ripgrep 13.0.0, GNU grep, git
Each command ran 7 times; I took the median time and recorded stdout bytes, line count, and the longest line
I did not measure tokens. "Amount" below means bytes and lines
The Python used for the measurement is below. It runs as pasted.
import osimport statisticsimport subprocessimport timeos.chdir(os.path.expanduser("~/repos/claudelab.net")) # replace with your own repositorydef run(cmd, n=7): times, out = [], b"" for _ in range(n): t = time.perf_counter() # rg exits 1 when nothing matches, so do not use check=True out = subprocess.run(cmd, shell=True, capture_output=True).stdout times.append((time.perf_counter() - t) * 1000) lines = [l for l in out.split(b"\n") if l] longest = max((len(l) for l in lines), default=0) return statistics.median(times), len(out), len(lines), longestCOMMANDS = [ ("rg -n", "rg -n -i hook content/articles/ja"), ("rg -l", "rg -l -i hook content/articles/ja"), ("rg -n -m1", "rg -n -m1 -i hook content/articles/ja"), ("git grep -n", "git grep -n -i hook -- content/articles/ja"), ("grep -rn", "grep -rn -i hook content/articles/ja"),]print("label\tms\tbytes\tlines\tlongest")for label, cmd in COMMANDS: ms, size, count, longest = run(cmd) print(f"{label}\t{ms:.0f}\t{size}\t{count}\t{longest}")
I leave out check=True because ripgrep reports "no match" as exit code 1. Raising on that would stop the measurement on a zero-hit day.
Result 1: speed was never a reason to choose
The actual numbers:
Command
Median
Output bytes
Lines
Longest line
rg -n (whole repo)
27 ms
771,629
3,443
1,010
rg -n (ja only)
17 ms
305,309
1,312
1,010
git grep -n
19 ms
305,309
1,312
1,010
grep -rn
30 ms
305,309
1,312
1,010
rg -l
14 ms
15,207
183
113
rg -c
16 ms
15,611
183
116
The three tools produced the same number of output bytes (305,309 each). The widest speed gap is 13 ms, which you will never feel when you hand a search to Claude.
What surprised me was the first row. Just by not scoping to content/articles/ja, the output grew to 771,629 bytes, because the English articles and src came along. Time went up by 10 ms. The amount grew 2.5x.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can now choose a search order that avoids missed matches and keeps context use down when a query matches hundreds of lines
✦You will be able to estimate the match count and the share of files a 250-line cutoff reaches, in seconds and before reading anything (script included)
✦You can stop spending time on search-tool speed comparisons and fix the thing that actually moves the numbers: how output is narrowed
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I had expected --max-columns to be the big lever, assuming long lines were what inflated the output. It cut only 2%.
The reason is line count and the line prefix, not line length. The body of a matched line has a median of 58 characters, a 90th percentile of 167, and a maximum of 517. Prose lines in this repository are short. Meanwhile, the content/articles/ja/claude-code/…mdx:123: prefix on each line adds up to about 112,000 characters, so roughly 37% of the output was paths and line numbers.
Switching to -o with 40 characters either side trims about 20%, but each match gets its own line, so the line count rose from 1,312 to 1,433.
Result 3: the first 250 lines reach less than a quarter of the matching files
Now the main point. In my environment, the Grep tool's description says that content mode cuts off at 250 lines unless head_limit is set (the default output_mode is files_with_matches, so the flood happens when content mode is chosen). Please check your own environment's tool descriptions for its limit.
So of the 1,312 lines, how many of the 183 files do the first 250 touch?
Files touched by the first 250 lines: 42 (23%)
Bytes in those 250 lines: 49,172 (16% of the full output)
The top 3 files alone account for 217 lines (17%): 88, 76, and 53 lines
61 files matched exactly once; 67 files matched five or more times
In other words, most of the 250 lines are filled by a few articles that mention the word again and again, and most of the 61 files that mention it once never come into view.
What you see also changed from run to run. ripgrep runs in parallel, so the order is not fixed. Six runs of the same command touched between 32 and 51 files in the first 250 lines, and the category mix moved around.
import subprocessfrom collections import Counterdef head_files(cmd, limit=250): out = subprocess.run(cmd, shell=True, capture_output=True).stdout.decode() top = out.splitlines()[:limit] files = {l.split(":", 1)[0] for l in top} # count the category part of content/articles/ja/<category>/<slug>.mdx return len(files), Counter(f.split("/")[3] for f in files)for i in range(6): n, cats = head_files("rg -n -i hook content/articles/ja") print(i + 1, n, dict(sorted(cats.items())))n, cats = head_files("rg -n --sort path -i hook content/articles/ja")print("sorted", n, dict(sorted(cats.items())))
An excerpt of the actual output, four of the six runs plus the path-sorted run:
The last row was the counterintuitive one. Not a single claude-code article came into view. The category with the most articles about hooks is claude-code, yet cutting the first 250 lines in path order shows only api-sdk and claude-ai, which sort earlier alphabetically.
If an answer came back saying "this is mostly covered in the api-sdk articles," the search would not have been wrong. The way the visible range was cut would have decided the answer. And nothing in the wording of the answer shows that a cut happened.
Estimating before you read: scout_hits.py
So I wrote a script that counts, before reading everything, how much will be cut. The problem it solves: it reports the match count, file count, and first-N reach in tens of milliseconds.
#!/usr/bin/env python3"""scout_hits.py — estimate hit count, file count and misses before reading all of rg's output.Usage: python3 scout_hits.py PATTERN [PATH] [--limit 250]"""import subprocessimport sysfrom collections import Counterdef rg(args): # rg exits 1 when nothing matches; only treat exit 2 as a real failure p = subprocess.run(["rg", *args], capture_output=True) if p.returncode == 2: sys.exit(p.stderr.decode(errors="replace")) return p.stdout.decode(errors="replace").splitlines()def main(): argv = sys.argv[1:] limit = 250 if "--limit" in argv: i = argv.index("--limit") limit = int(argv[i + 1]) del argv[i:i + 2] pattern = argv[0] path = argv[1] if len(argv) > 1 else "." lines = rg(["-n", "--sort", "path", "-i", pattern, path]) if not lines: print("no matches") return per_file = Counter(l.split(":", 1)[0] for l in lines) total_bytes = sum(len(l.encode()) + 1 for l in lines) head = lines[:limit] seen = {l.split(":", 1)[0] for l in head} first_m1 = rg(["-n", "-m1", "-i", pattern, path]) m1_bytes = sum(len(l.encode()) + 1 for l in first_m1) print(f"{len(lines)} lines / {len(per_file)} files / {total_bytes:,} bytes in full") print(f"first {limit} lines touch: {len(seen)} / {len(per_file)} files " f"({len(seen) / len(per_file):.0%})") top3 = per_file.most_common(3) print("top 3 files hold: {} lines ({:.0%})".format( sum(n for _, n in top3), sum(n for _, n in top3) / len(lines))) print(f"-m1 reaches every file in {len(first_m1)} lines / {m1_bytes:,} bytes") verdict = "start with -l or -m1" if len(lines) > limit else "fine to read in full" print("verdict:", verdict)if __name__ == "__main__": main()
The result for hook:
1312 lines / 183 files / 305,309 bytes in fullfirst 250 lines touch: 42 / 183 files (23%)top 3 files hold: 217 lines (17%)-m1 reaches every file in 183 lines / 49,494 bytesverdict: start with -l or -m1
For a narrower word, PreToolUse, the picture changes:
141 lines / 32 files / 38,516 bytes in fullfirst 250 lines touch: 32 / 32 files (100%)top 3 files hold: 48 lines (34%)-m1 reaches every file in 32 lines / 9,049 bytesverdict: fine to read in full
The hook run took about 64 ms. That is light enough for a preliminary count, and it makes the call "if it fits in 250 lines, just read it all" cheap to make.
Why count with --sort path? Without a fixed order the numbers shift every time you estimate. The real search keeps shifting, but I prefer to keep the estimate as a reproducible number. That is simply my preference.
The order I chose: -l, then -m1, then narrow down
Based on the measurements, here is the order I settled on:
File names only (-l): 15 KB and 183 lines give the whole picture, including which categories the hits are scattered across
One hit per file (-m1): 49 KB touches all 183 files and shows briefly in what context each file uses the word
Scope down, then read everything: narrow to the files or folders that caught your eye in step 2, then read all hits with -n
For step 3, it is easier to have Claude do the narrowing than to do it by hand. In CLAUDE.md I write something like this:
## How to search- For a term likely to match a lot, do not print contents first. Get the file list (files_with_matches) to understand the range- When viewing contents, start with one hit per file (the -m1 equivalent) and read everything only for the files that need it- If a search was cut off at the first N lines, say in the answer that the result is partial
The third line exists to leave a trace of the cutoff in the answer. As we saw, the wording of an answer does not show that a cutoff happened.
Pitfalls
Things that tripped me up while measuring:
--max-columns barely reduces volume: in a repository of short prose lines it saved only 2%. It matters more where logs or minified files dominate
-o adds lines: when one line has several matches, each match gets its own line (1,312 became 1,433)
The prefix is about 37% of the volume: the deeper the paths, the more bytes go to showing where rather than what. That is why -l is so light
git grep does not see untracked files: here everything was tracked, so the outputs matched, but a new article in progress would be outside its range. The same scope worry appears in taking stock of what Cowork does not read
Pick one term you often search for in your repository, run it through scout_hits.py, and see whether the verdict reads "start with -l or -m1"
For a term that gets that verdict, try -l then -m1 once, and compare what you see with the first 250 lines
Choosing one search angle with care reduces misses more than competing on search speed. That is what I took away from this measurement.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.