Grep finds strings.
duckgrep answers questions.
Give your coding agent a live, queryable model of your codebase (definitions, callers, imports, history), so structural questions take one query instead of a search loop.
uv tool install duckgrep
Both agents were asked (abridged): Which functions call show_server_banner, defined in src/flask/cli.py? List every one, including test functions.
- tool calls
- 3
- tokens
- 26.7k
- cost
- $0.0182
Grep
pattern: "show_server_banner"src/flask/cli.py:772:def show_server_banner(debug: bool, app_import_path: str | None) -> None: src/flask/cli.py:987: show_server_banner(debug, info.app_import_path) src/flask/app.py:657: cli.show_server_banner(self.debug, self.name)
Bash
$ awk 'NR>=930 && NR<=990 && /def /{print NR": "$0}' src/flask/cli.py; awk 'NR>=600 && NR<=657 && /def /{print NR": "$0}' src/flask/app.py941: def run_command( 969: def app(
Grep
pattern: " def run\(" path: src/flask/app.py546: def run(answer Flask.run, run_command correct
- tool calls
- 2 −1
- tokens
- 22.7k −15%
- cost
- $0.0141 −23%
query
SELECT * FROM callers('show_server_banner')src_path line caller ref_kind receiver resolution targets ---------------- ---- ----------- -------- -------- ---------- ------------------ src/flask/app.py 657 Flask.run call cli module show_server_banner src/flask/cli.py 987 run_command call local show_server_banner
Tests might reference it via mock; check quickly.
query
SELECT * FROM grep('show_server_banner')path line symbol text ---------------- ---- ------------------ ------------------------------------------------------------------------- src/flask/app.py 657 Flask.run cli.show_server_banner(self.debug, self.name) src/flask/cli.py 772 show_server_banner def show_server_banner(debug: bool, app_import_path: str | None) -> None: src/flask/cli.py 987 run_command show_server_banner(debug, info.app_import_path)
answer Flask.run, run_command correct
Same answer, both correct. With duckgrep: 1 fewer tool call, 15% fewer tokens and 23% less cost.
Two recorded runs from our A/B test (Claude Code 2.1.287, Sonnet 5.5, flask 3.1.0), replayed at the pace they ran. The duckgrep agent had the one-line instruction from the install steps. This question's cost saving sits at the median of the 453 structural questions both setups answered correctly. It is a typical run, not the best one (how it was chosen).
Both agents were asked (abridged): Which functions call eprint_nothing_searched, defined in crates/core/main.rs, directly or through one intermediate function?
- tool calls
- 3
- tokens
- 27.6k
- cost
- $0.0204
Grep
pattern: "eprint_nothing_searched"crates/core/main.rs:144: eprint_nothing_searched(); crates/core/main.rs:220: eprint_nothing_searched(); crates/core/main.rs:414:fn eprint_nothing_searched() {
Grep
pattern: "^\s*(pub )?fn |eprint_nothing_searched" path: crates/core/main.rs43:fn main() -> ExitCode { 77:fn run(result: crate::flags::ParseResult<HiArgs>) -> anyhow::Result<ExitCode> { 107:fn search(args: &HiArgs, mode: SearchMode) -> anyhow::Result<bool> { 144: eprint_nothing_searched(); 160:fn search_parallel(args: &HiArgs, mode: SearchMode) -> anyhow::Result<bool> { 220: eprint_nothing_searched(); 235:fn files(args: &HiArgs) -> anyhow::Result<bool> { … 6 more lines
Direct callers: search, search_parallel. Now I need their callers.
Grep
pattern: "\bsearch(_parallel)?\(" path: crates glob: **/main.rscrates/core/main.rs:87: Mode::Search(mode) if args.threads() == 1 => search(&args, mode)?, crates/core/main.rs:88: Mode::Search(mode) => search_parallel(&args, mode)?, crates/core/main.rs:107:fn search(args: &HiArgs, mode: SearchMode) -> anyhow::Result<bool> { crates/core/main.rs:126: let search_result = match searcher.search(&haystack) { crates/core/main.rs:160:fn search_parallel(args: &HiArgs, mode: SearchMode) -> anyhow::Result<bool> { crates/core/main.rs:190: let search_result = match searcher.search(&haystack) {
answer run, search, search_parallel correct
- tool calls
- 2 −1
- tokens
- 23.0k −17%
- cost
- $0.0159 −22%
query
SELECT * FROM callers('eprint_nothing_searched')src_path line caller ref_kind receiver resolution targets ------------------- ---- --------------- -------- -------- ---------- ----------------------- crates/core/main.rs 144 search call local eprint_nothing_searched crates/core/main.rs 220 search_parallel call local eprint_nothing_searched
query
SELECT * FROM callers('search') UNION ALL SELECT * FROM callers('search_parallel')src_path line caller ref_kind receiver resolution targets ---------------------------------- ---- --------------- -------- -------- ---------- --------------- crates/core/main.rs 87 run call local search crates/grep/examples/simplegrep.rs 29 try_main call local search crates/core/main.rs 126 search call searcher name 4 candidates crates/core/main.rs 190 search_parallel call searcher name 4 candidates crates/core/main.rs 88 run call local search_parallel
answer run, search, search_parallel correct
Same answer, both correct. With duckgrep: 1 fewer tool call, 17% fewer tokens and 22% less cost.
Two recorded runs from our A/B test (Claude Code 2.1.287, Sonnet 5.5, ripgrep 14.1.1), replayed at the pace they ran. The duckgrep agent had the one-line instruction from the install steps. This question's cost saving is close to the median of the 453 structural questions both setups answered correctly (how it was chosen).
What it saves
Across 494 questions: 12–19% less work and a quarter less cost, with answers just as accurate.
We ran Claude Code (Sonnet 5.5) on 494 structural questions from 25 open-source Python and Rust repos: who calls this, what reaches it through one more caller, what imports this module. Every setup had the same model and tools; the only difference was the MCP server. Each tool's agent had a one-line instruction to use it; Claude Code alone had none.
| Per question | Claude Code alone | + Serena | + duckgrep |
|---|---|---|---|
| Python 237 questions | |||
| Tool calls | 3.0 | 2.8 −5%† | 2.3 −18% |
| Round trips to the model | 3.6 | 2.9 −16% | 2.8 −19% |
| Tokens | 27.4k | 33.6k +23% | 22.5k −18% |
| Cost | $0.0267 | $0.0255 −4%† | $0.0203 −24% |
| Correct answers | 94% | 95% | 96% |
| Rust 257 questions | |||
| Tool calls | 3.3 | 3.9 +14% | 2.6 −16% |
| Round trips to the model | 3.7 | 3.8 +2%† | 3.1 −12% |
| Tokens | 29.4k | 45.9k +56% | 26.0k −12% |
| Cost | $0.0299 | $0.0336 +12% | $0.0226 −25% |
| Correct answers | 96% | 96% | 96% |
Why it saves. duckgrep answers "who calls this, and from which function" in one call. A grep hit gives a line, and the agent then has to read around it to find the function.
Without the instruction, agents still chose duckgrep for 75% of these questions, and cut tool calls by 12–19% and cost by 14–18%.
3,620 runs in all, made on 2026-10-02 with Claude Code 2.1.287. Values are typical per question: geometric means over tasks, so a few long runs don't swamp them. Changes compare each setup with Claude Code alone, task by task, with tool calls and round trips compared as 1 + n. † marks a difference that doesn't hold after correcting for all 160 comparisons. Every duckgrep change in calls, round trips, tokens and cost holds; the correct-answer rates differ by no more than chance in any setup. Full tables, the method and the harness: bench/RESULTS.md.
Why it matters
Coding agents find their way around a codebase with chains of grep → read file → grep again. That works for "where is this string." The questions that matter when changing code are structural: who calls this, what does it call, what breaks if I change it, what's dead. Each one turns into a loop of searches, file reads and guesses, paid for in turns and tokens.
It answers structural questions, not text matches.
"Who calls this, and from which function?" is one query, not a search loop. On django, finding every call to
get_or_createand the function it sits in took 22 calls (one grep, then 21 file reads to find the enclosing functions) and 58 KB of output. duckgrep answered it in one query and 9 KB. A simulated benchmark of one exhaustive answer, not an agent's saving.It answers questions no tool author anticipated.
Code-navigation tools give agents a fixed menu: find definition, find references. duckgrep gives them a query language. "Public functions in
pricing/with no callers outside their module" isn't a feature anyone built; the agent just writes the query.It joins code with its history.
The call graph, git churn and authorship sit in the same place, so an agent can ask "what's risky to change here," which text search can't answer.
It's trustworthy mid-edit.
The index refreshes before every query, so it reflects the agent's latest edits. Every call-graph edge says how it was resolved, so the agent knows when to trust the graph and when to go read the code.
How it works
duckgrep parses the repo with tree-sitter into a single DuckDB file and exposes it as one read-only query(sql) MCP tool. The tool's description carries the whole schema, so the agent can write a correct query on its first call.
Before every query, it re-reads the files that changed, so results reflect the agent's latest edits with no hooks. On django's 7,000 files, typical queries take 30–90 ms, and the check for changes before each adds about 140 ms when nothing changed (latency).
Python, TypeScript/TSX, JavaScript, Go and Rust. Other text files are indexed for grep() too. Built on DuckDB and tree-sitter.
Flask.make_response in three calls · flask 3.1.0WITH RECURSIVE up(p, q, d) AS ( SELECT path, qualname, 0 FROM symbols WHERE qualname = 'Flask.make_response' UNION SELECT e.src_path, e.src_scope, d + 1 FROM edges e JOIN up ON e.dst_path = up.p AND e.dst_qualname = up.q WHERE d < 3 AND e.ref_kind = 'call' AND e.resolution NOT IN ('name', 'ambiguous', 'unresolved') AND e.src_scope <> '') SELECT p AS path, q AS function, min(d) AS hops FROM up GROUP BY p, q ORDER BY hops, function path function hops ---------------- --------------------------- ---- src/flask/app.py Flask.make_response 0 src/flask/app.py Flask.finalize_request 1 src/flask/app.py Flask.full_dispatch_request 2 src/flask/app.py Flask.handle_exception 2 src/flask/app.py Flask.wsgi_app 3
SELECT c.path, c.n_commits, c.n_authors, count(DISTINCT e.src_path) AS used_by_files FROM file_churn c JOIN edges e ON e.dst_path = c.path AND e.src_path <> c.path AND e.resolution NOT IN ('name', 'ambiguous', 'unresolved') WHERE c.path LIKE 'src/%' GROUP BY ALL ORDER BY n_commits DESC, c.path LIMIT 5 path n_commits n_authors used_by_files ----------------------- --------- --------- ------------- src/flask/app.py 125 28 36 src/flask/helpers.py 81 21 20 src/flask/blueprints.py 61 16 12 src/flask/cli.py 61 17 6 src/flask/ctx.py 36 8 5
Install
-
Install the tool
uv tool install duckgrep -
Add it to Claude Code
claude mcp add duckgrep -- duckgrep mcpThe server answers at once and builds the index in the background; the first query waits for it.
-
Tell your agent to use it
Agents reach for grep out of habit. Add this line to your
CLAUDE.md, or your agent's instructions. It's the instruction the numbers above were measured with (there, appended to the system prompt).The repository in the current directory is indexed by duckgrep. To find code, call its query tool before Grep, Glob or Read: SELECT * FROM defs('name') finds where a symbol is defined, callers('name') who calls it, and grep('regex') searches the text and names each match's enclosing function. Read a file once you know where to look.
Other MCP clients run duckgrep mcp as a stdio server from inside the repo. The CLI (duckgrep callers, duckgrep q) and everything else are in the README.
Where it doesn't help
- Finding where to fix a bug from an issue report. On 230 SWE-bench tasks, agents with duckgrep used no fewer tool calls and were no cheaper, once corrected for multiple comparisons (results). An issue usually names an error message or a function that one grep finds, so there's nothing to save. Carrying the tool's description added 11–13% tokens on Python; they're cached, so cost stayed flat.
- "Where is this string." Grep is as good, and cheaper.
duckgrep pays off when the question is about how code connects.
FAQ
What can the agent query?
files- path, language, size, hash, line count, parse errors
symbols- name, qualified name (
Class.method), kind, parent, lines, signature, first doc line, exported refs- every identifier use (call, read, attribute, write, type, inherit, decorator, import, …), its receiver and enclosing scope
imports- as written, plus the repo file each resolves to
edges- reference → definition (the call graph), with a resolution tier
lines- full text, for
grep() commits,file_churn- git history
Table macros: defs, callers, callees, outline, grep, source. duckgrep schema prints exactly what the agent sees.
How does it resolve calls without a type checker?
Tree-sitter gives syntax, not types, so every edge says how it was resolved:
- Confident (
self,local,package,import,module,qualified): from scope and import analysis, including aliases, relative imports, star imports (Python and Rust),go.modprefixes, Cargo crate names, and re-exports: one hop in every language, up to three named or star hops in Python.qualified(Class.method) needs the class defined or imported in the calling file. typed(confident, Python for now):obj.method()where the receiver's class can be read from syntax:x = Foo()andFoo(...).m(); an annotation (p: Foo, or-> Fooone call away) or an unannotated function whose returns agree;self.attrset or annotated in the class, one more attribute hop (bot.exchange.m()), and class-body aliases (form_class = Foo); an imported module-level instance, a class defined in the calling function, andx = h.m()on a typedh; andself,super()andsuper(C, self)through base classes in other files. A name bound more than once gets no inference. The edge goes to the method on that class or its nearest in-repo base; a subclass override isn't followed. A receiver whose class is outside the repo isunresolved, not anameguess.name:obj.method()on a receiver of unknown type, matched by method name, one row per candidate up to 10.ambiguous: more candidates, or a method builtin types also have (get,append,clone). The target is left empty rather than guessed.unresolved: no in-repo target, such as the standard library or third-party code.
How accurate is the call graph?
Measured against jedi's goto-definition on sampled calls in Python repos (bench/RESULTS.md):
| Repo | Calls with an in-repo target | Resolved confidently | Precision |
|---|---|---|---|
| django | 1,554 | 95.3% | 100% |
| freqtrade | 1,598 | 95.4% | 100% |
| requests | 137 | 95.6% | 100% |
The rest fall to name, ambiguous or unresolved, which say so rather than guess. What remains are receivers syntax can't type (unannotated parameters, loop variables, longer chains, and runtime-built APIs such as Django's managers); see the roadmap.
Does it stay right while the agent edits code?
Yes. Before every query it lists the repo's files, stats each one and hashes only those whose size or modification time changed. Changed files have their rows swapped in one transaction, and call-graph edges that depend on them are recomputed when a query needs them. Tests check that the incremental index equals a full rebuild after renames, deletions and import changes. One known gap: an aliased re-export chain can keep a stale edge after an edit (roadmap).
How does it compare with Serena or a language server?
Serena gives agents a fixed set of language-server tools (find symbol, find references). duckgrep gives them one query over the whole parse, the call graph and git history, so questions combine in a single call. In the same benchmark, Serena added 23–56% tokens and cost 12% more on Rust; duckgrep cut cost by 24–25%, and accuracy was level across all three. The trade-off: a language server resolves types exactly; duckgrep's edges say when they're guessing.
How were the numbers measured?
The real Claude Code CLI ran in a scrubbed environment, with Bash, Read, Grep and Glob, under five setups that differ only in one MCP server and an optional one-line instruction. The 724 tasks were structural questions with answer keys from jedi and rust-analyzer, and issue localization from SWE-bench Lite, Multilingual and Live, with keys from the fix patches. Each setup is paired with plain Claude Code task by task, with bootstrap intervals and a Holm correction across all comparisons.
The harness, the suites and every number are in the repo: bench/eval and bench/RESULTS.md.