Four code graphs, four storage engines
enola, CodeGraph, graphify and codebase-memory-mcp all turn a repository into a queryable graph. They agree on almost nothing else. A benchmark-backed look at how each one stores, remembers and traverses a codebase, and what those choices cost.
Four open-source projects currently solve a version of the same problem: parse a repository, extract its structure into a graph, and hand that graph to an AI coding agent so it can answer questions without reading every file. They are enola, CodeGraph, graphify and codebase-memory-mcp.
They are written in Go, TypeScript with a Rust kernel, Python, and C respectively.
They persist to sorted JSONL, to on-disk SQLite, to a single JSON document, and to an
in-memory SQLite database that gets copied to disk in one shot. All four parse with
tree-sitter, though enola uses Go’s own go/ast for Go and reserves tree-sitter for its
other nine languages. Two of them independently arrived at the same storage engine from
opposite ends of the language spectrum, and then used it in opposite ways.
This post is about those decisions. It is not a ranking, and there is no winner at the end. Each of these tools promises something different, and each one is well built for what it promises. What I wanted to know was narrower and more interesting: given four teams solving a similar-shaped problem, where did they land, and what did each landing spot cost them?
Disclosure: I wrote enola. That is why it is in this comparison, and it is also why the method below holds the input data constant and models all four storage layers uniformly, including my own. If I had run my binary end to end and modelled the other three, every number in this post would deserve an eye-roll.
What is actually being measured
You cannot compare four extraction pipelines by running them, because they do not extract the same things. graphify ingests PDFs and video transcripts. codebase-memory-mcp indexes Kubernetes manifests. enola pins a baseline and grades the delta a change made. CodeGraph returns the matching source itself, in one payload, so the agent never opens a file. Running all four and comparing wall-clock times would produce a table where every cell measures different work, which is worse than useless because it looks rigorous.
So I held the data constant instead.
I generated one neutral corpus per repository: a plain set of nodes (a stable id, a kind, a name, a file, a line, a language) and edges (source, target, kind). That is the intersection of what all four tools store. Then I implemented each tool’s storage and memory layer as a faithful model, reading the identical corpus, and measured those.
Every model reproduces the real thing’s schema, pragmas, index set, serialization format and bulk-load posture, read from the source at these commits:
| Project | Commit | Dated | Language | First-party lines |
|---|---|---|---|---|
| enola | 39c1e01 |
2026-08-06 | Go | 147,570 |
| CodeGraph | d6d1728 |
2026-08-05 | TypeScript 84,391 + Rust 24,356 | 108,747 |
| graphify | 07b9143 (v0.9.34) |
2026-08-05 | Python | 121,907 |
| codebase-memory-mcp | 0d6f26a |
2026-08-06 | C | 286,793 (plus 431,008 vendored) |
All measurements here were taken on 6 August 2026, and this post is a snapshot with a short shelf life. Look at that Dated column: every one of these four projects had a commit within 48 hours of when I read it. These are actively developed tools, and the specific decisions described below are the ones in the tree on that day, not permanent properties. Schemas get migrated, index sets get tuned, storage backends get replaced outright. If you are reading this months later, treat the numbers as a record of four design philosophies at one moment, and the reasoning as the part that ages better than the measurements. The harness is described at the end so you can re-run it against whatever these projects look like when you find this.
The corpus is five public repositories spanning five languages and roughly a 15x range in size:
| Repository | Language | Nodes | Edges |
|---|---|---|---|
| tokio | Rust | 15,387 | 43,024 |
| gitea | Go | 36,044 | 101,850 |
| superset | Python | 83,123 | 124,996 |
| thingsboard | Java | 138,563 | 221,539 |
| grafana | Go | 222,941 | 458,442 |
Two passes. Pass A implements all four models in Python, so the language is held constant and the only variable is the storage shape. Pass B implements the same four in their native runtimes: Go, Node, C and Python. The difference between the two passes is what separates the shape from the runtime carrying it, and it turned out to be the most surprising result here.
These are models, not the products. No number below should be read as “tool X indexes repo Y in N seconds”. A model of enola’s storage layer tells you what sorted JSONL plus a CSR index costs. It tells you nothing about how good enola’s Go extractors are. I will say “the enola model” throughout, and I mean it literally.
One reassurance that the harness is sound: all eight implementations (four models times two passes) answer a frozen query set, and they must return identical answers or the comparison is void. They do, with one expected exception I will come back to. And because graphify’s native runtime is Python, its Pass B run is a re-run of its Pass A implementation, which makes it an accidental control group: two independent sweeps produced 2,385 ms and 2,397 ms, with peak memory identical to within 0.1 MB. Measurement noise here is under one percent.
How close are these four, really
Higher up than storage, there are two axes worth separating.
Axis one is the storage substrate. Do you put the graph in an embedded relational database, or in a flat file you parse yourself? CodeGraph and codebase-memory-mcp chose SQLite. enola and graphify chose a file.
Axis two is the unit of truth. Is the atom of your system a row that you join
against other rows, or a record that carries its own relations? CodeGraph and
codebase-memory-mcp store a node row and an edge row. enola stores a fact with a
relations array attached. graphify stores a concept node in a node-link document.
That gives four corners, not a line:
| Row as the atom | Record carries relations | |
|---|---|---|
| Embedded database | CodeGraph, codebase-memory-mcp | |
| Flat file | enola, graphify |
The interesting part is that the two pairs did not converge by copying each other. A TypeScript project and a pure-C project independently picked SQLite. A Go project and a Python project independently picked a file. And within each pair, the second decision went in opposite directions, which is where the engineering actually lives.
Where the four genuinely overlap: all of them extract symbols and edges, all of them persist locally with no network calls for code, and all of them expose the result to an agent over MCP or a CLI. Where they do not overlap at all: graphify maps documents, PDFs, images and video into the same graph and runs community detection over it; codebase-memory-mcp ships infrastructure-as-code nodes, a Cypher query engine and a cross-session coordination daemon; CodeGraph is built around an output contract rather than a feature, returning one dense verbatim payload sized to answer the question outright, and reports file reads cut to zero across its seven benchmark repositories; enola pins the architecture before a change, grades it after, and fails the change on a structural regression it introduced. Those are four different products. Only the middle layer is comparable.
The storage layer
CodeGraph: on-disk SQLite, indexes live the whole time
CodeGraph’s schema is the most conventional of the four, and conventional here is a
compliment. A nodes table with twenty columns, an edges table with foreign keys
cascading to it, seven node indexes, three edge indexes, and a unique identity index on
(source, target, kind, IFNULL(line,-1), IFNULL(col,-1)). That IFNULL fold is there
because SQLite treats each NULL as distinct, so coordinate-less synthesized edges would
never deduplicate against each other, and two extraction passes emitting the same edge
produced byte-identical duplicate rows.
Full-text search is an FTS5 external-content table (content='nodes'), kept in sync
by three triggers on insert, delete and update. External-content means FTS5 stores only
the inverted index and reads the original text back from nodes, which halves the
storage cost of search.
There is also a name_segment_vocab table, declared WITHOUT ROWID, mapping lowercase
word segments to symbol names, so that “OrderStateMachine” becomes order, state, machine.
The comment explains why it cannot just be an FTS query: FTS5’s tokenizer keeps camelCase
identifiers as a single token, so prose words in a user’s prompt would never match. The
segments are therefore materialized on the node write path.
The most interesting file in the project is src/db/wal-valve.ts, and it is interesting
because of what it measured. SQLite’s default WAL autocheckpoint (1000 pages) rewrites
hot B-tree and FTS pages into the main database file repeatedly during a bulk index. The
project measured that at about 95 percent of all disk I/O, and as the difference between
45 seconds and more than 19 minutes on storage doing roughly 150 random IOPS.
So CodeGraph defers autocheckpointing for the duration of the index. But unbounded deferral is its own failure: the WAL duplicates hot pages per commit and grows much faster than the database, and they measured a 5.9 GB WAL against a roughly 340 MB database. Hence a valve, which watches WAL growth on a timer and backfills with a PASSIVE checkpoint on a worker thread.
The subtlety documented there is genuinely good engineering. A WAL file’s size never shrinks. After a full backfill the writer restarts the WAL from the top and frames recycle inside the same file, so raw file size says nothing about the un-backfilled backlog, and a size-triggered valve degenerates into firing forever once the file passes its threshold. The valve therefore tracks size at last full backfill and triggers on growth beyond that baseline.
Every one of those decisions is a decision to keep the store queryable, crash-safe and incrementally updatable while it is being written. That is the bill CodeGraph pays, and the benchmark below is that bill.
codebase-memory-mcp: the same engine, the opposite posture
codebase-memory-mcp also chose SQLite. Then it did almost everything differently.
The index is built in a :memory: database with synchronous = OFF and no journal file
at all. Before the bulk load, cbm_gbuf_flush_to_store calls cbm_store_drop_indexes,
inserts everything inside a single transaction, commits, and then recreates all eight
indexes. When the graph is complete, the whole database is copied to disk with one
sqlite3_backup_step(-1), which copies every B-tree page in a single call.
Its FTS5 table is contentless (content='') rather than external-content, because
names are fed in pre-split by a cbm_camel_split SQL function that emits both the
original identifier and its word tokens. Contentless means FTS5 keeps only the inverted
index and cannot reproduce the source text, which is fine when the indexed text is a
derived form you never want back.
There is also a nice use of generated columns. The edges table declares
url_path_gen and local_name_gen as GENERATED ALWAYS AS (json_extract(properties, ...)),
and local_name_gen participates in the table’s UNIQUE constraint. That is how two named
imports from the same specifier stay distinct rows while every non-import edge collapses
to the empty string. The comment notes it must be NOT NULL, because NULLs never conflict
in a UNIQUE index and the deduplication would silently stop working.
So: CodeGraph optimizes for a store that is always live. codebase-memory-mcp optimizes for a batch build that is as fast as possible and then frozen. Same engine, opposite bet.
enola: sorted JSONL and a hash
enola writes facts.jsonl, one JSON object per line, plus receipt.json and
insights.json. There is no index on disk whatsoever.
The whole design turns on one property: the file is sorted before it is written, and the receipt carries a sha256 of the result. Sorting is what makes the bytes stable for a given commit (map iteration order would otherwise leak into the artifact), and byte stability is what makes a diff between two snapshots mean something. If you want to report that a change introduced a dependency cycle, you need the previous snapshot to be reproducible, or the delta is noise.
The cost is obvious and unavoidable: nothing is queryable until you have parsed the whole file and rebuilt an index in memory.
graphify: one JSON document
graphify writes a single graph.json via json_graph.node_link_data(G, edges="links"),
serialized whole, every time.
The tell that whole-file rewrite is the known failure mode is in export.py: before
overwriting, it reads the existing file back, counts its nodes, and refuses to write if
the new graph is smaller. If the existing file is non-empty but unparseable it fails
safe and refuses rather than overwriting, with a comment explaining that a transiently
unreadable graph.json would otherwise let a partial rebuild clobber a good one. That
is a sensible guard, and it exists because a single-document format has no way to
partially fail.
What the storage shapes cost
Pass A, all four models in Python. Build time is the model-specific write path only; parsing the corpus is common to all four and excluded.
Build time (ms)
| Repository | enola | CodeGraph | codebase-memory-mcp | graphify |
|---|---|---|---|---|
| tokio | 80 | 356 | 167 | 225 |
| gitea | 188 | 867 | 411 | 467 |
| superset | 294 | 1,878 | 806 | 751 |
| thingsboard | 541 | 3,778 | 1,689 | 1,324 |
| grafana | 964 | 5,845 | 2,576 | 2,385 |
On-disk bytes per stored item (node or edge)
| Repository | enola | CodeGraph | codebase-memory-mcp | graphify |
|---|---|---|---|---|
| tokio | 60.5 | 214.4 | 197.7 | 118.1 |
| gitea | 59.9 | 210.9 | 193.8 | 118.6 |
| superset | 90.0 | 363.7 | 304.0 | 170.7 |
| thingsboard | 119.6 | 503.7 | 415.8 | 211.1 |
| grafana | 79.2 | 310.0 | 267.8 | 150.9 |
Load to queryable (ms, warm page cache)
| Repository | enola | CodeGraph | codebase-memory-mcp | graphify |
|---|---|---|---|---|
| tokio | 54.9 | 0.4 | 0.3 | 110 |
| gitea | 129 | 0.4 | 0.3 | 206 |
| superset | 197 | 0.4 | 0.3 | 322 |
| thingsboard | 351 | 0.4 | 0.3 | 556 |
| grafana | 660 | 0.4 | 0.3 | 1,010 |
Three things fall out of this.
The database models pay at build time and get load time for free. A SQLite store is queryable the instant it is opened, at any scale, because the index is already on disk. 0.4 ms on tokio and 0.4 ms on grafana. The file models pay the inverse: cheap to write, and then a full parse plus index rebuild every time a process starts, scaling linearly with the graph.
For a long-running MCP server that starts once, that load cost amortizes to nothing. For a CLI invoked per query, it is the dominant cost. Neither design is wrong; they are answers to different deployment questions.
Indexes are most of the disk. enola’s JSONL is 60 to 120 bytes per item. The SQLite models are 200 to 500. That is not JSON being efficient, it is the difference between storing data and storing data plus seven indexes plus an FTS index plus a unique constraint. You are buying query capability with bytes, which is a completely reasonable trade if you intend to run queries.
The same engine, two postures, is a 2.3x gap. On grafana, the CodeGraph model takes 5,845 ms and the codebase-memory-mcp model takes 2,576 ms. Both are SQLite. Both insert the same rows. The entire difference is that one keeps its indexes and FTS triggers live throughout the load while writing through an on-disk WAL, and the other drops its indexes, builds in RAM, and copies pages out once.
That gap is the price of CodeGraph’s incrementality. It is not waste. CodeGraph auto-syncs on every file change, so its store must stay queryable and crash-safe mid-write; codebase-memory-mcp’s batch build has no such obligation. But it is worth naming clearly, because “we both use SQLite” hides a 2.3x difference in what that actually means.
The memory layer
enola: interned ids and CSR
The most instructive thing in enola’s memory layer is that its own source documents the
regression that produced it. internal/facts/graph.go describes the shape it replaced:
two map[string][]Edge plus a map[string][]int fact index, which cost 854 MiB on the
Linux kernel’s 1.89 million facts. Roughly 1.8 million map keys times three, about 3.6
million separately allocated edge slices, and 5.4 million 32-byte edge structs holding
string headers.
The comment makes the point that matters: those slices made the graph about 21 live objects per fact, and for a long-running MCP server that is a garbage-collector scan cost paid on every query, not once at startup.
The replacement interns every node name to a dense uint32, keeps relation kinds in a
uint16 table (the vocabulary is small and closed), and holds both forward and reverse
adjacency in compressed sparse row form: one offset array indexed by node id, plus flat
target and relation arrays shared by every node. Nothing is allocated per node.
codebase-memory-mcp: it owns every byte
src/foundation/ contains arena.c, slab_alloc.c, str_intern.c, vmem.c,
mem_profile.c and hash_table.c, alongside vendored mimalloc. This is a project that
decided to manage its own memory completely, and paid for that decision in surface area:
it also had to write its own regex compatibility layer, thread compatibility layer and
filesystem compatibility layer.
The pipeline is RAM-first and releases memory after indexing. The neatest trick in it is
that file bodies are held LZ4-compressed and scanned in place by a fused Aho-Corasick
automaton (cbm_ac_scan_lz4_batch), so candidate patterns are matched without ever
decompressing the file.
There is a second in-memory layer too. graph_buffer.c stages the entire graph with
integer temp ids and remaps them to real ids at flush time, so the graph exists in C
memory before it exists in SQLite memory before it exists on disk.
CodeGraph: it does not own its memory, so it budgets it
CodeGraph cannot do any of that, because it runs on V8. What it does instead is make the
runtime’s limits explicit, and src/resolution/memory-budget.ts is the best example of
this genre I have read.
os.freemem() reads /proc/meminfo, which inside a container reports the host’s
memory rather than the cgroup’s. The comment records the consequence: a resolver pool
sized by cores alone was OOM-killed five times in a 7 GB-capped container. So the module
reads cgroup v2 then v1 limits directly.
On macOS the failure is inverted. os.freemem() counts only free pages, and macOS
deliberately keeps RAM full of reclaimable cache, so a mostly idle 64 GB machine reports
about 1 GB free. That capped the resolver pool at two workers where the CPU term allowed
six, measured as 3.0 s versus 1.9 s of resolution settle time. So the module reconstructs
what Activity Monitor calls available memory: free plus inactive plus speculative plus
purgeable.
Elsewhere, the SQLite adapter exposes iterate() alongside all() specifically so
unbounded scans stay O(1) in row count, after materializing every symbol on a dense
project drove the heap into an OOM.
graphify: NetworkX, and everything that comes with it
graphify holds the graph in a NetworkX DiGraph: a dictionary of dictionaries of
dictionaries, where every node, every edge and every attribute is a Python object. It is
by a wide margin the most expensive per-edge representation of the four.
It is also the reason graphify gets Leiden community detection, shortest paths, and the entire NetworkX algorithm library for free. That is a real trade, and it pays off in the next section.
One consequence is structural rather than about performance. A NetworkX DiGraph is a
simple digraph: it cannot hold two edges between the same pair of nodes, so a calls
edge and an imports edge between the same two symbols collapse into one. graphify has a
multigraph_compat.py that probes for MultiDiGraph support, but its docstring is explicit
that this is preparation for a future opt-in and that no call sites use it yet.
This is the one place where my harness’s cross-validation disagreed, which is how I found it. Measured on the corpus, the collapse affects 1.05 percent of edges on tokio, 0.02 percent on thingsboard, and essentially zero elsewhere. So it is a real structural limit with, on this corpus, a small practical effect. Both halves of that sentence are worth keeping.
What the memory shapes cost
Peak RSS at query time, Pass A (all in Python, so this is the shape, not the runtime):
| Repository | enola | CodeGraph | codebase-memory-mcp | graphify |
|---|---|---|---|---|
| tokio | 32 MB | 31 MB | 25 MB | 97 MB |
| gitea | 44 MB | 40 MB | 29 MB | 178 MB |
| superset | 66 MB | 45 MB | 34 MB | 290 MB |
| thingsboard | 109 MB | 54 MB | 26 MB | 525 MB |
| grafana | 161 MB | 66 MB | 26 MB | 827 MB |
The SQLite models are close to flat, because they never hold the graph in memory at all; they hold a bounded page cache over an mmap’d file. codebase-memory-mcp’s model stays at 26 MB on a graph more than ten times larger than the one where it used 25 MB. That is the strongest argument for the database approach and it has nothing to do with speed.
The in-process models scale with the graph, as they must. But the gap between them is entirely layout: 161 MB versus 827 MB for the same nodes and edges, a 5.1x difference between packed integer arrays and Python objects.
Now the query side. Four-hop breadth-first search from the twenty highest out-degree nodes, Pass A:
| Repository | enola | CodeGraph | codebase-memory-mcp | graphify |
|---|---|---|---|---|
| tokio | 0.44 | 3.89 | 3.71 | 0.68 |
| gitea | 1.50 | 12.1 | 11.1 | 2.53 |
| superset | 0.89 | 8.21 | 7.35 | 1.69 |
| thingsboard | 0.80 | 7.09 | 6.51 | 1.74 |
| grafana | 4.20 | 33.9 | 31.3 | 7.95 |
And this is the mirror image. The database models are roughly 8x slower at multi-hop traversal, because every hop is a fresh B-tree descent through an index, while the in-process models are following an array offset. Point lookups tell the same story more mildly: enola’s model does 500 exact name lookups in 0.30 ms on grafana, CodeGraph’s in 1.95 ms.
So the honest summary of the storage and memory sections together:
- Database models: pay at build, free at load, flat memory, slower traversal.
- In-process models: cheap build, pay at load, memory scales with the graph, much faster traversal.
Each design wins exactly the axis it optimized for and loses the one it traded away. There is no dominated corner here, which is why “which is best” is the wrong question.
Algorithms
The algorithmic surfaces diverge much more than the storage layers do, and the split tracks the memory decision closely.
graphify gets the most algorithm per line of code, because NetworkX is right there.
Community detection is Leiden via graspologic, falling back to Louvain in NetworkX when
graspologic is unavailable. cluster.py does something worth copying: before
partitioning, it rebuilds the graph with nodes and edges inserted in sorted order,
because community detection is sensitive to insertion order and an unsorted graph would
produce different communities run to run. Deterministic output is a deliberate choice
here, not an accident. Elsewhere it uses MinHash for deduplication and rapidfuzz for name
matching.
codebase-memory-mcp has the widest algorithmic surface, and it is worth being precise
about where that surface comes from, because “pure C, zero dependencies” is easy to
misread. The graph and query layer really is its own C: an Aho-Corasick automaton that
scans LZ4 blocks in place (428 lines), MinHash and simhash (538), a recursive-descent
Cypher parser and evaluator with a bounded variable-length path depth (4,972), a “Hybrid
LSP” type-resolution layer covering a dozen languages (2,243), and bloom filters holding
each file’s referenced names in lsp_surface.ref_bloom.
The infrastructure underneath all of that is vendored and statically compiled in: SQLite, which is where BM25 ranking actually comes from via FTS5, plus tree-sitter and its 159 grammars (about 98 programming languages and about 61 config, build, markup, templating and schema formats, which I come back to later), LZ4 and zstd, mimalloc, a regex engine, and a JSON parser. SQLite alone is 280,675 lines, and the vendored total of 431,008 lines is larger than the 286,793 first-party ones. Zero dependencies here means nothing to install at runtime rather than nothing borrowed, which is exactly what the README says and a fair description of a statically linked binary. The hand-written part is the graph layer sitting on top.
CodeGraph is built around deferred resolution, which is the most distinctive
architectural choice of the four. Extraction does not try to resolve cross-file
references. It writes them to an unresolved_refs table as pending, and a later
resolution pass either deletes the row (resolved) or marks it failed and records a
name_tail, the last dotted segment of the reference. That name_tail exists so a
future incremental sync can cheaply retry failed references when a changed file
introduces a symbol that might now satisfy them. Around that sit framework synthesizers
for callbacks, C function pointers, GoFrame and the Swift/Objective-C bridge.
enola resolves during extraction with module-wide fixpoint passes. Route prefixes are
the clearest case: Go mux/chi PathPrefix mounts, Axum .nest() calls and FastAPI
include_router prefixes are all composed interprocedurally, so a route is stored at its
true runtime path rather than the literal string in the handler file. Its explainers do
cycle detection, layering checks, god-class and hotspot analysis, and its cross-repo
linker uses token-set Jaccard over declaring files to decide whether two repositories
genuinely share code.
The thread connecting all four is that every one of them has an unresolved-reference
problem, and no two solve it the same way. A call to greet() in a dynamic language
may or may not refer to something the indexer has seen. CodeGraph parks it in a table and
retries later. enola runs a fixpoint until the graph stops changing. graphify emits the
edge and tags it INFERRED so the consumer can decide. codebase-memory-mcp precomputes a
bloom filter of referenced names per file so a later pass can skip files that certainly
do not reference a given symbol.
Four reasonable answers. graphify preserves uncertainty on each inferred edge. CodeGraph’s approach is the most suited to incremental updates. enola does the most resolution work before serving a single-shot graph and reports what remains unresolved. codebase-memory-mcp’s approach is the fastest. That is the whole comparison in miniature.
The documentation, and which promises are checkable
All four projects make performance claims. They are not equally verifiable, and sorting them by type is more useful than arguing about magnitude.
Claims you can check yourself, locally. codebase-memory-mcp says it supports 158
languages via vendored tree-sitter grammars. The repository contains 159 grammar_*.c
files, so the count is real. But counting files is the shallow check, and I ran it first
and moved on, which was a mistake worth showing rather than hiding.
Read the grammar names and the definition starts doing work. Of the 159, about 61 are not
programming languages in any ordinary sense: gitignore, gitattributes, dotenv,
csv, diff, jsdoc, sshconfig, properties, requirements, plus configuration and
data formats (JSON, YAML, TOML, XML, INI, KDL, Pkl, Nickel), build files (Make, CMake,
Meson, Ninja-adjacent gn, Dockerfile, Just), markup (Markdown, reStructuredText, HTML,
BibTeX, Mermaid), stylesheets (CSS, SCSS), templating (Jinja2, Liquid, Blade, Go
templates), and schema or query languages (SQL, GraphQL, Protobuf, Thrift, Cap’n Proto,
Prisma, Smithy). That leaves roughly 98 actual programming languages.
None of that makes the claim false. Tree-sitter itself calls every one of these a language, indexing Dockerfiles and YAML and Protobuf is genuinely useful in a code graph, and codebase-memory-mcp advertises infrastructure-as-code indexing as a feature precisely because it treats those files as first-class. The claim is true under a definition the ecosystem shares. It just invites the reading “158 programming languages,” which would be wrong by about sixty.
And this is not a habit specific to one project, which is why it belongs in this section rather than in a complaint. All four count non-programming formats in their language lists. CodeGraph’s own coverage table includes Liquid, a templating language. graphify’s tree-sitter dependencies include JSON. enola’s supported-language list, which I wrote, includes Terraform/HCL, Ansible, OpenAPI, GraphQL and Protobuf alongside Go and Rust. If that is a sin, it is one I have committed too, and the useful lesson is that “N languages” is a claim whose definition you have to read, not a number you can compare across tools.
CodeGraph publishes a per-language cross-file coverage table (95.8 percent for TypeScript, 86.7 percent for Rust, 73.8 percent for Liquid) along with its definition: the share of symbol-bearing source files with at least one resolved cross-file dependent. Publishing the definition and the low numbers alongside the high ones is what makes it checkable rather than decorative. enola’s determinism claim is verifiable by running the binary twice and comparing the receipt hash.
Claims that are real but only verifiable inside the author’s own harness. graphify reports LOCOMO recall@10 of 0.497 and LongMemEval-S accuracy of 76 percent, with the judge blind-validated against a second judge at 90.6 percent agreement and Cohen’s kappa of 0.81. codebase-memory-mcp cites a preprint reporting 83 percent answer quality, 10x fewer tokens and 2.1x fewer tool calls across 31 repositories, and separately advertises 120x fewer tokens for five structural queries against file-by-file search. CodeGraph reports 88 percent fewer tool calls, 62 percent fewer tokens and 44 percent lower cost across seven benchmark repositories.
None of these is dishonest. They are all documented, several are unusually rigorous, and graphify’s judge validation is more methodological care than most commercial vendors show. But a reader cannot falsify them without rebuilding the harness, and every one of these projects has claims in this category. Recognising the category is the useful move, not disputing the numbers.
One entry in this category deserves singling out, because it runs the other way. Immediately after its headline token-reduction numbers, CodeGraph’s README documents an axis on which it is worse: because it returns one dense verbatim payload that then stays in the context window, it leaves about 80 percent more retrieval context resident at the end of a multi-turn session than a file-reading agent does, measured at 67k tokens against 18k on one repository. The README states plainly that fewer tokens processed and a larger persistent footprint are both true at once, and tells you to budget for it. I have read a lot of these READMEs. Publishing the axis where your own tool loses, directly under the axis where it wins, is rare enough to be worth naming.
Claims that are not really falsifiable at all. “The fastest complete code graph” raises the question of what oracle defines complete. “The fastest and most efficient code intelligence engine for AI coding agents” has no stated comparison set. “The index is never stale, and there is nothing to re-run” is true only within the file watcher’s debounce window, which is a fine thing to promise but is a different promise from the one the sentence makes. “Full-indexes the Linux kernel (28M LOC, 75K files) in 3 minutes” omits the hardware, and hardware is the variable that moves that number most.
I want to be even-handed about enola here, because it is easy to grade your own work
generously. enola’s docs/BENCHMARKS.md opens by declining to run the retrieval
benchmark the other three lean on, arguing that retrieval measures how quickly an agent
reaches code it was going to read anyway. That is a defensible position and I still hold
it. It is also, unavoidably, a positioning choice: it means enola does not report the one
number readers can most easily compare across tools. Naming that is more honest than
presenting it purely as rigour.
The general pattern, and it is not specific to these four: the more competitive a project’s category, the more its README leans on the third category of claim. That is a market dynamic, not a character flaw.
What “supporting a language” actually means
The last section ended on a number you have to read the definition of, and that pulls a bigger thread. Once you notice that 158 and 11 are not the same kind of number, the obvious question is what “supporting” a language means at all, because it is clearly not one thing.
Handling a language in a code graph is a ladder, and each rung is much more work than the one below it:
- Parse it. A tree-sitter grammar exists, so you get an AST instead of text.
- Extract definitions. You get symbols: functions, classes, methods.
- Extract calls and imports. You get edges, but only the ones written literally in the file.
- Resolve references across files. Now
greet()in one file points at the declaration in another, and you have an actual graph rather than a pile of names. - Resolve them with type information. Now
user.profile.display_name()resolves through an import, a generic and an inheritance chain to the right method three modules away, the way an IDE’s “Go to Definition” would.
A grammar buys you rung one. Essentially all the value for an agent is on rungs four and five, and the distance between rung three and rung four is where most of the engineering in all four of these projects actually goes.
The advertised numbers sit on different rungs, which is why they cannot be compared:
| Project | Advertised | What rung that number is | Measured per language? |
|---|---|---|---|
| codebase-memory-mcp | 158 languages | rungs 1 to 3 for all 158, rung 5 for 10 | no |
| CodeGraph | full extraction and cross-file resolution | rung 4, claimed uniformly | yes, 22 languages |
| graphify | ~40 languages | rung 3, rung 4 generically | no |
| enola | 11 code languages | rung 4 | 13 language tags across 38 repos |
To codebase-memory-mcp’s real credit, it is the only one of the four that publishes this ladder explicitly, and it does so in plain language. Its README describes a two-layer architecture: a tree-sitter pass that “runs for every one of the 158 languages”, and a Hybrid LSP pass, a C implementation of type-resolution algorithms, that covers ten entries (Python, TypeScript with JavaScript and JSX and TSX, PHP, C#, Go, C with C++, Java, Kotlin, Rust, Perl). Then the sentence that answers your question directly: “Languages without a Hybrid LSP pass yet fall back to textual resolution, so you always get some answer.”
That is an honest description of a real gap. For the other 148, a cross-file call edge is
matched by name rather than resolved by type, which is the difference between knowing
that something called send exists elsewhere and knowing which send was called. On a
codebase with one send, textual resolution is right. On a codebase with fifteen, it is a
guess with good manners. So 158 is a true statement about rung three and a misleading one
if you read it as rung five, and the README says so if you get as far as the Hybrid LSP
section.
CodeGraph makes the opposite trade: many fewer languages, and a claim that they all get the same treatment. Its language section says every listed language gets “full structural extraction and cross-file resolution into one graph, no per-language setup”, which is a rung-four claim made uniformly. What makes it checkable rather than a boast is the coverage table underneath it: 22 languages with a measured percentage each, from 100 percent on Python, PHP and Ruby down to 84.2 percent on Lua and 73.8 percent on Liquid, with the definition of the metric stated and the residual attributed to genuine static analysis frontiers. Publishing 73.8 next to 100 is what makes the 100 believable.
graphify sits lower on the ladder and is quieter about it. It parses roughly 40 languages
and does cross-file calls / imports / inherits resolution generically, by name, with
only two language-specific cross-file resolvers registered (Ruby and Pascal). Its
compensating move is the honest one available at that rung: every edge is tagged
EXTRACTED or INFERRED, so the uncertainty is visible in the output rather than
resolved silently. If your resolution is name-based, saying so per edge is better than
claiming a tier you do not reach.
enola claims the fewest languages of the four and measures all of them on public repositories, and its benchmark doc carries a limitations section that narrows its own claims per language: Kotlin and Swift are measured on real applications but at 312 and 382 parsed files, the small end of its corpus, so it says the framework constructs are exercised on production code while the scale claims are not. Vue and Svelte are measured on real applications too, but at 56 and 13 routes, which it calls enough to show file-based routing works and not enough to characterise it. Swift is excluded from one section entirely, with the reason given. I wrote that, so treat it as a description of a choice rather than a boast: a small measured number is easier to stand behind than a large asserted one, and it is also a much smaller promise.
So the useful question to ask any of these tools is not “how many languages” but two different questions. Which rung, for the languages I actually use? And is that measured or asserted? A tool that resolves your language by type is worth more to you than one that parses 158 and name-matches yours, and a tool that publishes 73.8 percent for a language is telling you more than one that publishes nothing.
Whether any of this shows up in a benchmark like the one in this post: it does not. The storage and memory measurements above hold the extracted graph constant precisely so storage can be compared, which means they say nothing about extraction quality. That is a real limitation of the method, and it is worth stating twice.
What the implementation language actually bought
This is the result I did not expect, and it is why Pass B exists.
Comparing each model in Python against the same model in its native runtime, on grafana:
| Model | Runtime | Build | 4-hop traversal | Peak RSS |
|---|---|---|---|---|
| enola | Python to Go | 964 to 286 ms (3.37x) | 4.20 to 0.92 ms (4.57x) | 161 to 67 MB (2.41x) |
| CodeGraph | Python to Node | 5,845 to 6,030 ms (0.97x) | 33.90 to 34.09 ms (0.99x) | 66 to 102 MB (0.64x) |
| codebase-memory-mcp | Python to C | 2,576 to 1,940 ms (1.33x) | 31.28 to 32.09 ms (0.97x) | 26 to 12 MB (2.26x) |
| graphify | Python to Python | 2,385 to 2,397 ms (0.99x) | 7.95 to 7.96 ms (1.00x) | 827 to 827 MB (1.00x) |
Read the middle two rows again.
Rewriting the CodeGraph storage model from Python into Node changed its build time by three percent and its traversal time by one percent. Rewriting the codebase-memory-mcp model from Python into C made the build 1.33x faster and the traversal not at all faster. Meanwhile, rewriting the enola model from Python into Go made it 3.4x faster to build and 4.6x faster to traverse.
The explanation is simple once stated. When your graph lives in SQLite, SQLite does the work, and SQLite is C in every one of these cases. Your language is a thin client issuing statements. It determines your memory envelope (the C model uses 12 MB where the Node model uses 102 MB, an 8.9x spread on identical data) but it barely touches your latency, because the latency is B-tree descents happening below your runtime.
When your graph lives in your own process, your language is the whole performance story.
Go’s packed uint32 CSR arrays against Python’s array module doing the identical
algorithm is a 4.6x traversal gap.
So the choice of an embedded database is, among other things, a decision to make your implementation language mostly irrelevant to your speed. For CodeGraph that looks like a very good trade: TypeScript buys the npm ecosystem, straightforward agent-config integration for eight different tools, and a large contributor pool, and SQLite means the language costs almost nothing in query latency. The Rust kernel is the escape hatch for the one part where the language would dominate, parsing, and it is built to cross the JS boundary exactly once per file.
For codebase-memory-mcp the trade is less obvious. C bought the arena allocator, in-place LZ4 scanning, a static binary with no runtime, and a genuinely excellent memory profile. It cost roughly 287,000 lines of first-party code, carrying another 431,000 vendored beneath it, and included writing its own arena and slab allocators, a string interner, and separate regex, threading and filesystem compatibility layers. On the query path measured here, C’s advantage over Python was zero, because both were waiting on the same SQLite. The memory win is real and large; the latency win, for this layer, is not there.
For enola, Go is doing real work: the CSR rewrite is only worth writing because the runtime can express packed arrays cheaply, and the 854 MiB regression its own comments describe is a GC pressure story that only exists in a garbage-collected language. Go bought a single binary and cheap concurrency, and charged a GC that made the naive layout untenable at kernel scale.
For graphify, Python bought tree-sitter bindings, Leiden, rapidfuzz and NetworkX for almost no code, and the ability to treat documents, images and video as first-class graph citizens. It charged a per-object memory floor of 827 MB where packed arrays needed 67 MB. If your graph is tens of thousands of concepts and your value is in the algorithms you run over it, that is a good trade. If your graph is the Linux kernel, it is not, and graphify does not claim to be for that.
Where this leaves things
Four projects, four defensible positions:
CodeGraph optimized for a store that is never stale. It pays 2.3x the build time of the other SQLite design and carries the largest on-disk footprint, and in exchange it can be written to continuously while remaining queryable and crash-safe. Its WAL valve is the most carefully reasoned piece of systems code in any of the four repositories.
codebase-memory-mcp optimized for raw indexing throughput and a flat memory profile. It builds in RAM with the indexes dropped, dumps once, and then serves queries in a 26 MB resident set that barely moves as the graph grows sevenfold. It paid for that with the largest first-party codebase of the four by a factor of two.
enola optimized for delta precision: it is a regression gate, and the graph is the substrate rather than the product. Sorted JSONL and a hashed receipt exist so two snapshots can be diffed and the diff believed, because a gate that also reports pre-existing findings gets switched off within a week. It pays a load cost linear in graph size on every process start.
graphify optimized for what you can do with the graph once you have it, and for the graph containing more than code. It carries by far the highest memory cost per edge, and gets Leiden, path-finding, community labelling and a document pipeline in exchange.
The comparison that keeps being interesting to me is CodeGraph against codebase-memory-mcp, because they picked the same engine and then disagreed about everything else, and both were right given their different goals. Indexes live or dropped. On-disk or in-memory. External-content FTS or contentless. Continuous sync or batch freeze. If you ever want a demonstration that “we use SQLite” tells you almost nothing about a system’s performance characteristics, those two are it.
And the finding I will actually carry forward: choosing an embedded database is a decision about which resource your implementation language controls. Put the graph in SQLite and your language sets your memory envelope but not your latency. Keep it in your own process and your language sets both. That is worth knowing before you pick either one.
Further reading
Two earlier pieces of mine cover the layer above this one, where a parsed graph and an architectural model stop being the same thing:
- Parsing Code Is Not The Same As Mapping Architecture, on why a syntax tree and a symbol graph are the start of the work rather than the end of it. That is the rung-four problem from the language section, argued at length.
- Codebase Memory Is Not an Architecture Map, on the difference between retrieving fragments of a codebase and holding a model of it.
Both are on enola.tech and both are written from inside one of the four tools compared here, so read them as a position rather than a survey.
Method, corpus and harness notes: the neutral corpus was generated once and stripped of tool-specific fields, so every storage model reads identical input. Both passes answer a frozen query set, and all eight implementations returned identical results apart from graphify’s documented DiGraph edge collapse. Load times are warm page cache. Measurements were taken on an Apple silicon laptop on 6 August 2026; the ratios between models are the point, not the absolute numbers. All four projects are open source and actively developed, and every source detail cited above is at the commits listed near the top, so anything here can be checked against that exact tree and re-checked against a newer one.