A trie stores strings by sharing their prefixes: each edge is a character and each path from the root spells a prefix, so every key that starts with a given prefix sits in one subtree. The fundamentals, child representations, radix compression and static encodings are covered in Trie in depth; this article assumes them and builds the thing tries are most often deployed for: a search box that suggests the best few completions on every keystroke, ranks them sensibly, and still helps when the user mistypes.

The design question is not whether a trie can find all keys with a prefix (it can, trivially) but how to return the best k of possibly millions of matches in well under a millisecond of server time, and how to do it with typos, without scanning the subtree.

Advertisement

The shape of the system

An autocomplete service has two halves that meet at an immutable artefact. Offline, a job aggregates candidate strings (past queries, product names, titles), normalises and filters them, assigns each a score, builds the trie and freezes it into a versioned snapshot. Online, each request normalises the typed prefix with the same function, walks to the prefix node, extracts the top k completions, falls back to a fuzzy search when exact matches are scarce, and returns a merged, deduplicated list.

Autocomplete on a trie: an offline build and an online query pathOffline (hourly or daily)Query logs, catalogueraw strings + countsNormalise and filterNFKC, casefold, blocklistScorefrequency, recency, qualityBuild triesubtree best scoresFreeze to snapshotflat arrays, versionedOnline (every keystroke)Normalise the typed prefixsame function as the buildWalk to the prefix nodeO(length of prefix)Best-first top-kheap ordered by subtree bestToo few results?fuzzy walk, 1-2 editsMerge, rerank, returndedupe, penalise editsatomic swapThe two paths share one normalisation function and meet only at the immutable snapshot.
Autocomplete splits into a batch build that produces an immutable trie snapshot and a per-keystroke query path that only reads it.

Keeping the online path read-only removes locking from the hot path entirely, and makes every response reproducible from a snapshot version.

Normalise once, in one function

The trie matches characters exactly, so whatever is equal for the user must be equal as bytes. Apply Unicode normalisation (NFKC folds compatibility forms such as full-width letters; NFC is the conservative choice), case folding, whitespace collapsing and, where the language allows, accent removal. Store the display form separately at the terminal node so the user sees "Café Noir" while the key is "cafe noir". Ship the normalisation function as a single shared library used by the build and the server; a one-character difference between the two, such as an apostrophe variant, produces prefixes that silently match nothing.

Advertisement

Ranking: store the best score in every subtree

Each key has a score: query frequency, click-through, recency-weighted counts or a model's estimate. The trick that makes top-k fast is to store at every node the highest score anywhere beneath it. Insertion updates that maximum along the path; deletion or score decreases require recomputing it upward from the children, which is one reason production systems rebuild rather than mutate.

import heapq

class Node:
    __slots__ = ("kids", "score", "word", "best")
    def __init__(self):
        self.kids = {}        # character -> Node
        self.score = None     # set when a stored key ends here
        self.word = None      # display form of that key
        self.best = 0.0       # highest score anywhere in this subtree

class Autocomplete:
    def __init__(self):
        self.root = Node()

    def add(self, word, score):
        node = self.root
        node.best = max(node.best, score)
        for ch in word:
            node = node.kids.setdefault(ch, Node())
            node.best = max(node.best, score)    # maintain the subtree maximum
        node.score, node.word = score, word

    def locate(self, prefix):
        node = self.root
        for ch in prefix:
            node = node.kids.get(ch)
            if node is None:
                return None
        return node

Best-first top-k

With subtree maxima, extracting the k best completions is a best-first search. Put the prefix node on a max-heap keyed by its best score. Repeatedly pop the top entry: if it is a finished key, emit it; otherwise push its own key (if one ends there) and each child, keyed by their scores. Because a subtree's key is an upper bound on everything inside it, the first k keys emitted are exactly the top k, and subtrees whose best score is lower than the k-th result are never expanded. Priority queues and heaps covers the heap operations used here.

def top_k_from(start, k=5):
    """Best-first search below one node: pop subtrees by their best score."""
    out, tie = [], 0
    heap = [(-start.best, tie, start, False)]
    while heap and len(out) < k:
        neg, _, node, is_word = heapq.heappop(heap)
        if is_word:
            out.append((node.word, -neg))
            continue
        if node.score is not None:          # the key ending here, as its own item
            tie += 1
            heapq.heappush(heap, (-node.score, tie, node, True))
        for child in node.kids.values():
            tie += 1
            heapq.heappush(heap, (-child.best, tie, child, False))
    return out

ac = Autocomplete()
for w, s in [("car", 9), ("cat", 8), ("care", 7), ("catalog", 6),
             ("dog", 5), ("card", 4), ("cart", 2)]:
    ac.add(w, s)
print(top_k_from(ac.locate("ca"), 3))    # [('car', 9), ('cat', 8), ('care', 7)]

Worked trace for prefix "ca" with k = 3. The heap starts with node ca (best 9). Pop ca: push child r (best 9) and child t (best 8). Pop r, the node for "car": push the key car (9) and its children d (4), e (7) and t (2). Pop key car: emit it. Pop node "cat" (8): push key cat (8) and child a (6). Pop key cat: emit it. Pop node "care" (7): push key care (7). Pop key care: emit it, and stop. The catalog, card and cart entries were pushed or skipped but never expanded.

Each emitted key costs heap operations along its path, roughly k x depth x branching factor pushes in the worst case, independent of how many million keys share the prefix. If that is still too slow for very short prefixes, precompute the top-k list at shallow nodes (depth three or less, say), where subtrees are huge and lists are few, and use best-first search below.

Typo tolerance: Levenshtein along trie paths

Users type "cqr" for "car". Edit distance between two strings is computed by dynamic programming one row per character, and the key observation is that a trie path is a string built one character at a time, so the DP row for a node can be derived from its parent's row. Walking the trie depth-first, each node computes one row of length |query| + 1. If the last entry is within the edit budget, the node's path is a fuzzy match for the whole query; if the smallest entry in the row exceeds the budget, no extension of this path can come back within it, so the entire subtree is skipped. Shared prefixes share work, which is the same reason tries beat checking each dictionary word separately.

def fuzzy_nodes(root, query, max_edits=1):
    """Trie nodes whose path is within max_edits of query (Levenshtein)."""
    hits = []
    def walk(node, ch, prev):
        row = [prev[0] + 1]
        for i in range(1, len(query) + 1):
            row.append(min(row[i - 1] + 1,                        # insertion
                           prev[i] + 1,                           # deletion
                           prev[i - 1] + (query[i - 1] != ch)))   # match / substitution
        if row[-1] <= max_edits:
            hits.append((row[-1], node))
        if min(row) <= max_edits:              # otherwise no descendant can recover
            for c2, kid in node.kids.items():
                walk(kid, c2, row)
    first = list(range(len(query) + 1))         # DP row for the empty path
    for ch, kid in root.kids.items():
        walk(kid, ch, first)
    return hits

def suggest(root, query, k=5, max_edits=1, penalty=0.5):
    """Complete from every node within max_edits; scale scores down per edit."""
    best = {}
    for edits, node in fuzzy_nodes(root, query, max_edits):
        for word, score in top_k_from(node, k):
            s = score * (penalty ** edits)
            if s > best.get(word, -1.0):
                best[word] = s
    return sorted(best.items(), key=lambda kv: -kv[1])[:k]

print(suggest(ac.root, "cqr", 3))    # [('car', 4.5), ('care', 3.5), ('card', 2.0)]

Here "cqr" matches the node for "car" with one substitution; completions below it are scored at half their normal value, so an exact-prefix suggestion with a decent score still outranks a typo correction. Scaling the edit budget with query length is a common heuristic: no edits for one to three characters, one edit up to about seven, two beyond, because short prefixes with an edit match almost everything. Many systems also require the first character to match exactly, which shrinks the search dramatically and matches how people mistype.

Freezing the trie for serving

Pointer-and-dictionary nodes are convenient for building but costly to serve: in a managed language each node can cost over a hundred bytes. The serving snapshot flattens the trie into arrays in breadth-first order: for each node, the offset of its first child and its child count, the child edge labels in sorted order, the subtree best score and an index into a table of display strings. Child lookup is a binary search over a short contiguous label run, the arrays can be memory-mapped, and loading a new version is just mapping a new file. Radix compression (collapsing single-child chains) typically removes most nodes from a query-log trie, since long tails of characters rarely branch.

Build, swap and freshness

Rebuild the snapshot on a schedule, validate it (key count within expected bounds, a set of golden prefixes returning expected results, no blocked terms present), then publish it and switch a single reference in each server atomically. Old snapshots stay on disk for instant rollback. For freshness between builds, such as a breaking news term, keep a small mutable overlay trie that receives new keys, query both at request time and merge results; the overlay is discarded at the next full build. Apply blocklists in the build, not only at query time, so a blocked string cannot appear even if a query-time filter fails. A Bloom filter is a cheap pre-check if the blocklist is large and query-time checks are still required.

Operating it

Clients should debounce keystrokes (tens of milliseconds) and cancel in-flight requests when a newer prefix is typed, so the server never answers stale prefixes. Cache responses for the shortest prefixes, which dominate traffic and change only with the snapshot. Track p50 and p99 server latency, the empty-result rate per prefix length, the fuzzy-fallback rate, and outcome metrics such as suggestion acceptance and keystrokes saved. A rising empty-result rate after a deploy almost always means the build and the server disagree about normalisation.

Failure modes

SymptomCauseFix
Prefixes that should match return nothingBuild and query normalise differentlyOne shared normalisation library and a golden-prefix test
Latency spikes on one- and two-character prefixesBest-first search over enormous subtreesPrecomputed top-k lists at shallow depths, response cache
Fuzzy results swamp exact onesEdit penalty too weak or budget too large for short inputScale edits with length; penalise per edit; require exact first character
Offensive or stale suggestionsFiltering only at query time, scores never decayBlocklist in the build; recency-weighted scores
Memory grows with every buildOld snapshots kept mappedReference-count snapshots and unmap after the swap drains
Suggestions vanish after a score updateSubtree maxima not recomputed on decreaseRebuild, or recompute maxima upward on every change

Trade-offs

A sorted array of keys with binary search finds the prefix range compactly, but ranking within a large range needs extra structure, such as a range-maximum index; the trie's subtree maxima give that directly. A hash table keyed by every prefix answers instantly with precomputed lists but stores each key once per prefix, which is affordable only for short keys or shallow depths. Finite state transducers, which also share suffixes, are far more compact for static sets and are what several search engines use for completion; they are harder to build and inspect. A full-text engine handles mid-word and multi-token matches that a prefix trie cannot. For classic "type the start of a phrase" autocomplete over up to tens of millions of entries, a frozen, ranked trie is simple, fast and easy to reason about.

Key takeaway: <p><strong>What to do next.</strong> Ranked autocomplete is a trie plus two ideas: subtree maximum scores that make best-first top-k exact and fast, and a Levenshtein row carried down trie paths that makes typo tolerance prune whole subtrees.</p><ol><li>Write one normalisation function and use it in both the build and the server.</li><li>Store the subtree best score at every node and implement best-first top-k with a heap.</li><li>Add fuzzy fallback with an edit budget scaled to query length and a per-edit score penalty.</li><li>Freeze builds into flat, versioned, memory-mappable snapshots with validation before an atomic swap.</li><li>Precompute or cache results for the shortest prefixes and measure p99 per prefix length.</li><li>Track empty-result rate and acceptance rate, and alert when either moves after a deploy.</li></ol>