Design code search across repositories
Open in the interactive app: practice, quiz, mock interview →In one minute
- Index trigrams, then verify. A trigram index turns substring and regex search into a boolean query over three-character keys. It returns a small candidate set, and the real matcher runs only on those. The index is a filter, never the answer.
- Plan the regex before touching the index. Extract the literals any match must contain. A regex with no usable literal cannot be narrowed, so give it a budget and a refusal path.
- Shard by content, not repository. Hashing the blob id spreads load and stores each distinct file once, though forks and vendored copies dominate. The price is that every query fans out to every shard.
- Reindex on push by diffing, with immutable segments. A push becomes a small segment plus tombstones, merged in the background, against a stated freshness target.
- Enforce permissions twice. Rewrite the query with the caller's visible repositories, then filter the merged results again.
Requirements
Functional
- Search all indexed repositories for an exact substring or a regex, filtered by repository, organisation, language and path.
- Return ranked results with file, line numbers and snippets, with pagination.
- A push to a default branch becomes searchable quickly; deleted files and repositories disappear.
- Callers only see code in repositories they can read, and visibility changes take effect quickly.
Non-functional
- Literal queries: p50 under 200 ms, p99 under 1 s. Regex queries get a hard budget (for example 2 s) and a clear "too broad" error beyond it.
- Freshness p99 under 60 s from push; permission revocation under 1 minute; availability 99.9%.
- Index size within a small multiple of the deduplicated corpus.
Out of scope
- Semantic search, code navigation, searching commits or issues, and non-default branches.
Back-of-envelope
Assume a hosting service with 50M indexed repositories. The sizes below are round figures I chose for the arithmetic, and the corpus shape follows what GitHub published for its beta, about 45M repositories, 115 TB of code and 15.5B documents, of which only about 28 TB was unique after deduplication (GitHub Engineering).
- Corpus: 100 TB raw, about 30 TB unique after blob-level dedup.
- Index: a non-positional trigram index was about 20% of source size in Google Code Search, 77 MB for 420 MB of Linux sources (Cox); positional trigrams cost about 3x in Zoekt (Zoekt design). Take postings at 30% plus compressed content at 30%: 30 TB × 0.6 ≈ 18 TB per copy, 54 TB with 3 replicas.
- Why not grep: ripgrep scans about 0.6 GB/s per core (GitHub Engineering). 30 TB ÷ 0.6 GB/s = 50,000 core-seconds per query, about 24 s even on 2,048 cores. Brute force serves 0.01 QPS.
- Query load: 5M daily searchers × 10 searches = 50M/day ≈ 580 QPS average, 1,700 QPS at a 3x peak.
- Fan-out cost: 18 TB ÷ 18 GB per shard = 1,000 shards. Most shards have a tiny candidate set for a selective query, so assume 1 ms of CPU on average: 1,700 QPS × 1,000 shards × 1 ms = 1,700 cores, about 55 hosts of 32 cores in total. Storage needs 54 TB ÷ 2 TB usable per host ≈ 27 hosts, so CPU binds. Run 3 replica sets of about 20 hosts (900 GB each), roughly 60 hosts and 1,900 cores, with headroom for a lost replica. Queries on common trigrams cost 100 ms or more per shard and are the real sizing risk.
- Push load: 5M pushes/day ≈ 58/s average, 175/s peak. At 3 changed blobs per push and 8 KB per blob that is 120 GB/day of changed text, of which about 40% is new content, so 48 GB/day of new unique text, 17.5 TB/year, about 88 TB over 5 years. With the index, 54 TB now grows to roughly 150 TB, and 450 TB with 3 replicas.
- Bandwidth: 100 hits × 1 KB of snippets = 100 KB per response, so 1,700 × 100 KB = 170 MB/s at the edge. Internal fan-out is 20 hosts × 1,700 QPS ≈ 34k small RPCs per second.
API
POST /v1/search
{ "q": "class\\s+\\w+Handler", "mode": "regex", // "literal" or "regex"
"filters": { "org": "acme", "lang": ["Go"], "path": "internal/**", "repo": null },
"page_size": 50, "cursor": null, "timeout_ms": 2000 }
200 { "results": [ { "repo": "acme/api", "path": "internal/http/handler.go", "blob": "9f1c...",
"score": 12.7, "lines": [ { "n": 41, "text": "...", "ranges": [[6, 20]] } ] } ],
"next_cursor": "opaque", "partial": false, "shards_failed": 0,
"index_lag_ms": 3400 }
422 { "error": "query_too_broad", "hint": "add a literal of 3+ characters or a repo/org filter" }GET /v1/blob/{blob_id}?repo=acme/api&path=... # file view, permission checked
POST /internal/events/push { repo_id, before_sha, after_sha, ref } # from the Git servicepartial and shards_failed make a degraded answer visible to the caller instead of silently incomplete. index_lag_ms lets a client explain "your commit is not indexed yet".
Data model
- Repository table (relational, keyed by
repo_id):owner,visibility,org_id,default_branch,indexed_sha,index_state,pushed_at. Authoritative for what is indexed and at which commit. - Blob store (content-addressed by Git object id): the text of each distinct file. It is the source for rebuilds, so the index never has to be.
- Document in a shard: dense local
doc_id,blob_id,lang,size,static_scoreand the list of(repo_id, path)references. One document may belong to thousands of repositories. Higher static score gets a lower id, which lets a query stop early. - Posting lists (per shard):
ngram -> sorted doc_ids, delta and varint encoded, memory-mapped, plus lists by language and repository id for filters. - Permission data (cache from the access service): a
publicflag,org -> repo_idsanduser -> private repo_ids, resolved at query time with short TTLs. - Shard function:
hash(blob_id) mod N. Each shard's state is its offset in an ordered event log, so a replica rebuilt from the log reaches the same state.
High-level design
flowchart LR G["Git service"] -->|push event| K["Event log"] K --> C["Crawler / differ"] C -->|new blobs| B["Blob store"] C -->|blob ids by shard| K2["Per-shard queues"] K2 --> I["Shard indexer (segments)"] I --> S["Shard replicas (SSD, mmap)"] U["Client"] --> Q["Query service"] Q --> P["Planner: regex to n-gram plan"] Q --> A["Access service + cache"] Q -->|fan out| S S -->|top hits| Q Q -->|merge, re-filter| U
Write path. The Git service emits a push event to an ordered log. The differ compares before_sha and after_sha, lists added, changed and deleted paths, and keeps only blob ids the system has not seen. New blobs are written to the content-addressed store and routed to their shard by hash(blob_id). The shard indexer builds an immutable segment of the new documents, appends tombstones for removed (repo, path) references, and publishes it to the replicas. A repository state row records the indexed_sha once the shards that care have acknowledged.
Read path. The query service parses the request, plans the regex into an n-gram boolean expression, and asks the access service for the caller's readable repositories. It rewrites the query to carry the permission clause, sends it to one replica of every shard, and each shard intersects posting lists, verifies candidates against stored content and returns its top hits by score. The query service merges the lists, re-checks permissions on the survivors, builds snippets and returns a page.
Deep dives
1. The index and the query planner
Options. Word-level inverted indexes, as in Lucene, split text at word boundaries, so they cannot answer ndl inside handler, or punctuation such as ->. Word indexes do use k-grams for wildcards (Stanford IR book), but only over dictionary terms. A suffix array over the whole corpus answers any substring directly, and livegrep runs RE2 on the candidate regions it finds (Nelhage), but a suffix array needs several bytes per input byte, which hurts at 30 TB and is awkward to update. Trigram posting lists sit between: Cox found 2-grams too unselective and 4-grams too numerous (Cox).
Choice. Trigram posting lists per shard, with positions only where they pay for themselves. Zoekt keeps positional trigrams and checks that two rare ones sit at the right distance, at about 3x corpus size (Zoekt design). GitHub's engine also builds n-gram indexes (trigrams in its worked example) separately for content, symbols and paths (GitHub Engineering). Variable-length keys that make common text rarer are a possible refinement, not something I rely on here.
The planner is the hard part. A literal limits becomes lim AND imi AND mit AND its. A regex is analysed bottom-up for the strings, prefixes and suffixes any match must contain; Google.Search becomes the AND of the trigrams of both words, and a set of alternatives becomes an OR (Cox). Case-insensitive search multiplies alternatives, and . yields no constraint at all. The planner must therefore return a plan with an estimated cost: intersect the rarest lists first, use document-frequency statistics to estimate candidates, and when the plan is ANY, reject the query unless filters narrow it to a bounded number of repositories. Return query_too_broad rather than burning a thousand cores.
Verification. Check candidates with a linear-time engine such as RE2, never a backtracking one, so a user pattern cannot run in exponential time.
2. Sharding and incremental reindex
Options. (a) Shard by repository: filters can skip shards, but big repositories create skew and forks are indexed repeatedly. (b) Shard by blob id: even load and one copy of each distinct file, but every query touches every shard.
Choice. Shard by hash(blob_id). GitHub made the same choice, keying shards by Git blob object id to spread documents evenly and avoid hot shards, and reports dedup and delta indexing cutting 115 TB to about 28 TB (GitHub Engineering). Because a document lists every (repo, path) that contains it, a fork costs a few bytes of reference per file, not a second copy. Repo and org filters become posting lists inside each shard.
Incremental reindex. A push is a diff. Added or changed blobs make a new tiny segment in their shards. Deleted files and renamed paths remove a (repo, path) reference, and a document with no references is dropped at merge time. A shard applies events in log order, so a shard replica is a deterministic function of its stream, and reaching a given offset is a precise statement of freshness. Background merges bound the segment count. Merging also re-sorts documents by score so the best ones get the lowest ids, as in GitHub's compaction. A force-push is a diff against the stored indexed_sha; if that commit is unreachable, re-walk the repository.
Rule of thumb. The index is derived data. Jepsen found Elasticsearch 1.5 losing acknowledged writes under faults and recommended not using the search index as system of record (Jepsen). Keep Git and the blob store authoritative, make every indexing step idempotent by (blob_id, repo_id, path), and be able to rebuild any shard from the blob store.
3. Ranking, permissions and tail latency
Ranking. Results should rank by usefulness, not by index order. Cheap signals include whether the match is a symbol definition, in a comment or string, on a word boundary, near other matches, file path length and recency; Zoekt lists several (Zoekt design). Split ranking in two. A static score per document (popularity, language, whether it is vendored or generated) is baked into document order at merge time, so each shard can stop after enough high-scoring verified hits. A dynamic score (match quality) reorders those few.
Permissions. The index holds a repo-to-blob mapping, so permission is a property of the (repo, path) reference, not of the blob. A blob in one public and one private repository must be visible only through the public reference to a stranger. The query service builds a clause: PublicRepo() OR RepoIDs(user_private_repos) (GitHub's query rewrite adds such clauses and filters again after merging, per its write-up). Do not expand huge repository lists per query; cache per-org bitmaps with short TTLs and invalidate on change. The post-merge check asks the access service about the few hundred surviving results, so a stale cache can only cause a delayed removal of results, never a leak of anything the second check denies. Fail closed: if the access service is unreachable, return only public results.
Tail latency. A query waits for the slowest of about 1,000 shard answers. If each shard exceeds 100 ms in 1% of cases, then 1 − 0.99^1000 is essentially 100% of queries hit at least one slow shard. Four mitigations stack. Send each shard request to one replica chosen by load, and hedge to a second replica after the p95 latency. Give every request a deadline and have shards return partial results by score order rather than finishing a full intersection. Cap per-query CPU. Report partial: true when a shard times out, because a visible partial answer beats a silent one.
Down the stack
Layer 1The contract
Every returned line matches the query exactly, because verification runs on the real text. Results are complete up to index_lag_ms and the partial flag. A user never sees a result from a repository they cannot read, and a push is searchable within the freshness target. Not promised: matches in skipped files (binary, huge, invalid UTF-8, too many distinct trigrams; Cox's indexer skips such files, per his article), which a production system must surface to users.
Layer 2The mechanism
A shard file holds a format version, a trigram dictionary mapping each trigram to a posting-list offset, the posting lists, the document table, and compressed content with a line-offset table for snippets. Posting lists store gaps between sorted doc ids as variable-length integers, so a common trigram costs a byte or two per document. Zoekt uses uint32 offsets, which caps content near 1 GB per shard and shows that shard size is a design constraint (Zoekt design). Queries evaluate the plan with iterators that advance the shortest lists first, so full lists are never materialised; GitHub also reads lists lazily (GitHub Engineering). Each surviving doc is verified and kept in a bounded heap.
Layer 3The machine
Shards are read-mostly and mapped with mmap, so the page cache decides which lists are hot. A query touches few, mostly sequential lists, so NVMe (about 100 us per random 4 KB read, several GB/s sequential) suffices: a cold 1 MB list costs about 0.4 ms. GitHub reports about one core per query per shard, roughly 640 QPS on a 64-core host, with per-shard p99 near 100 ms (GitHub Engineering). Throttle merges, which compete for SSD bandwidth, and publish segments by atomic manifest swap.
Why it's built this way
- Grep needs 50,000 core-seconds per query at 30 TB, so build an index.
- Code has no word boundaries and users want regexes, so index fixed-size substrings; trigrams balance selectivity against key count.
- A trigram match is only necessary, so verify; this keeps the index free of positions.
- Most of the corpus is duplicate, so key content by hash and store references; that also gives the shard function.
- Content-hash sharding forces fan-out, so tail latency, early termination and replicas become the central operational concerns.
Failure modes
- 10x query load. Fan-out multiplies it by the shard count. Shed load by query class (regex first), cache popular literal queries for seconds, and add replica sets rather than shards.
- A query with no usable literal (
.*,\w+). The planner flags an unbounded plan and returns422; with a repo filter it may run on a bounded candidate list under a deadline. - A common trigram.
formatches most documents. Keep frequency statistics in the planner, intersect the rarest list first, and consider longer or variable-length grams for common text. - Slow or dead shard replica. Hedged requests and failover hide it;
partialandshards_failedexpose any query that still lost a shard. Alert on the partial-response rate. - Region outage. The index is derived, so a second region keeps replicas fed from the same event log; log lag decides freshness after failover.
- Mass push or monorepo import. Blob-hash sharding spreads it over all shards. Throttle per-repo indexing so it cannot starve others' freshness.
- Permission change race. The post-merge check closes the gap within the cache TTL; for urgent cases an invalidation event evicts the repository from caches.
In the wild
- GitHub, 2023: a new code search engine, Blackbird. The earlier Elasticsearch-based search dated from when GitHub had about 8M repositories, and the count grew past 200M. GitHub built a Rust engine over n-gram indexes, sharded by Git blob id, and its beta covered about 45M repositories and 115 TB of code in 15.5B documents. Deduplication and delta indexing cut the corpus to about 28 TB and halved a full index build from about 36 to about 18 hours; per-shard p99 is around 100 ms (GitHub Engineering).
- Google Code Search, 2012 write-up: trigram index for regex search. Russ Cox described the index behind Google's Code Search and published an open-source version. A regex is converted into a trigram query that selects candidate files, and the full regex then runs only on those. On Linux 3.1.3,
hello worldnarrowed 36,972 files to 25 and ran in 0.01 s against 1.96 s for brute force. The index was about 20% of source size, and case-insensitive queries gave a much smaller speedup (Cox). - Jepsen on Elasticsearch, 2015: a search index is not a system of record. Jepsen showed Elasticsearch 1.5.0 losing acknowledged writes under partitions, pauses and process kills (about 10% under
kill -9because the translog was fsynced on a timer). The advice was to keep data in a safer database and continuously upsert it into the index, which is the same rule that makes a code-search index rebuildable from Git and the blob store (Jepsen).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Trigram postings + verify | Exact regex and substring, small index, simple to update | Weak on common or short literals, needs a regex planner | General code search with regex support |
| Positional trigrams (Zoekt style) | Fewer false candidates, phrase checks without reading content | About 3x corpus size, heavier writes | Smaller corpora, latency matters most |
| Sparse n-grams | Rarer keys for common text, fewer candidates | More complex build and query planning | Very large corpus where common grams dominate cost |
| Suffix array (livegrep style) | Direct substring and regex lookup on one structure | Several bytes per input byte, hard to update incrementally | Single-repo or batch-built indexes |
| Shard by blob hash | Even load, dedup, hot-shard free | All queries fan out | Millions of repos with many forks |
Leadership lens
- Roll out in stages. Index public default branches of a few thousand repositories and compare with grep to measure recall, then shadow real queries, then a cohort, and add private repositories only after a red-team review of the permission path.
- Ownership. Ingest and freshness, the query engine and planner, and permissions and ranking. Permissions get a named owner and a suite of known-leak scenarios run on every release.
- Cost drivers. SSD and replicas dominate (about 450 TB at five years above), and fan-out CPU follows queries times shards. Track the dedup ratio as a first-class metric.
- Ask in design review. What happens to a query with no literal? Who is paged for the freshness SLO? How long does a shard rebuild take? Which permission check fails closed?
- Build first. A single-node trigram index with verifier and permission filter, then the push differ, then sharding.
Check yourself
Why must a trigram query always be followed by a verification step, and what would break if you trusted the index alone?
Show answer
A document can contain every trigram of the query without containing the query as a contiguous string (for example
abc bcdforabcd). The index proves a necessary condition only, so without verification you would return false positives, and any regex that the planner approximates would return wrong lines.Your service shards by blob hash and p99 per shard is 100 ms across 1,000 shards. Why is the user-visible p99 much worse, and what do you change?
Show answer
The query waits for the slowest shard, so a 1% per-shard slow rate hits nearly every query at 1,000-way fan-out. Hedge to another replica after about p95, impose a deadline with partial results ordered by score, and cap per-query CPU.
A file exists in a public fork and a private repository, with the same content. How does the design keep it from leaking?
Show answer
Visibility is attached to each
(repo, path)reference, not to the blob. The query is rewritten to public repositories plus the caller's readable ones, and results are filtered again after the merge, failing closed if the access service is down.Using the numbers in this lesson, why does dedup matter more than index compression?
Show answer
Dedup cut the corpus from 100 TB to about 30 TB, a 70% reduction before indexing, and everything downstream (index, replicas, build time, I/O) scales with unique content. Compression typically gains a smaller constant factor on the remainder.
Sources and further study
Primary
- Regular Expression Matching with a Trigram Index (Russ Cox): the regex-to-trigram-query analysis, index size and timings behind Google Code Search.
- The technology behind GitHub's new code search: n-gram indexes, blob-id sharding, deduplication, Kafka-driven ingest and the permission rewrite at 45M repositories.
- Regular expression search with suffix arrays (Nelhage): the suffix-array alternative used by livegrep, with its limits.
Systems docs
- Zoekt design notes: shard layout, positional trigrams, ranking signals and size caps.
- sourcegraph/zoekt: indexing commands, the index server and the search API.
- google/codesearch: the open-source trigram indexer and
csearchtool from the article above. - Jepsen: Elasticsearch 1.5.0: why a search index should be derived data.
Books
- Introduction to Information Retrieval, Manning, Raghavan and Schütze, chapter 3 "Dictionaries and tolerant retrieval": k-gram indexes and posting-list intersection.
- Designing Data-Intensive Applications, Martin Kleppmann, chapter 3 "Storage and Retrieval": segments, merging and full-text indexes in log-structured storage.
Other curricula
- donnemartin/system-design-primer: has no code-search scenario; its search coverage stays at the level of a generic search cluster, which is why substring and regex search needs the extra material here.
Watch
- Lessons from building GitHub code search (Luke Francl, Strange Loop 2023): the engineering team's own account of building the n-gram engine.
Free resources
- Stanford IR book: k-gram indexes for wildcard queries: the textbook treatment of the same idea.
- livegrep: runnable regex code search to experiment with.
- About GitHub code search: the user-facing behaviour and syntax of a production system.
- ripgrep: the baseline scanner whose throughput motivates the index.