TRENDING
Subway turnstiles showing a green ENTER sign and a red DO NOT ENTER sign side by side
September 27, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
Macro photo of a brass keyhole with a key partially inserted in a wooden door
September 27, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel
Akamai's glass headquarters tower in Cambridge, Massachusetts, with the company's logo visible on the facade
September 27, 2026
Anthropic’s $11.6 Billion Akamai Deal Flips the Usual AI Financing Script
A staircase of sequential canal lock chambers at Bingley Five Rise Locks, each gate validating the water level before the next stage
September 27, 2026
How to Build a Multi-Stage AI Agent Pipeline in Python to Stop Errors From Compounding
The E. Barrett Prettyman United States Court House in Washington, D.C., home to the U.S. Court of Appeals for the D.C. Circuit
September 27, 2026
The D.C. Circuit’s 2-1 Ruling Turns Anthropic’s Own Guardrails Into a Supply-Chain Risk
27 Sep 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Five alphabetical thumb-index tabs cut into the edge of a dictionary, each labeled with a letter range
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
Five sample state-issued EBT benefit cards fanned out on a white background
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
A real wooden outdoor sandbox filled with sand and toys, empty of people
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
September 26, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 209 Posts
News 210 Posts
Learning Hub 180 Posts
Home/Learning Hub/How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
Learning Hub

How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete

Build a trie from scratch in Python, reproduce two real bugs (a false-positive search and a delete that corrupts a sibling word), and benchmark it against a naive list scan.

September 26, 2026 19 Min Read
10

Type the first few letters into any search box, product catalog, or command-line shell, and something has to answer the question “which of the things I know about start with this?” fast enough that it feels instant. A naive answer is to scan every stored item and check if it starts with what you typed. That works fine for a hundred items. It gets noticeably slow once you have hundreds of thousands, because the cost grows with both how many items you store and how long each one is.

Table Of Content

  • What You Will Build
  • Prerequisites
  • Step 1: Why a Plain List Does Not Scale
  • Step 2: A First Trie, With a Bug Baked In
  • Step 3: Fix It With an End-of-Word Marker
  • Step 4: Turn It Into Autocomplete
  • Step 5: Rank Suggestions by What People Actually Pick
  • Step 6: Benchmark the Trie Against the Naive List at Scale
  • Step 7: Delete Without Corrupting a Sibling Word
  • Step 8: Fix Deletion So Nothing Is Removed That Something Else Needs
  • Putting It All Together
  • Why This Matters Beyond Autocomplete: Trie in Your Own Router
  • Common Mistakes and How to Verify Each Step Worked
  • Confirming Everything Works End-to-End
  • Next Steps

A trie (also called a prefix tree or digital tree) is a tree structure built specifically to answer prefix questions without scanning anything. Instead of storing whole words as single values, a trie spells each word out one character at a time as a path through the tree, so that every word sharing the same first few letters also shares the same first few nodes. Looking up a prefix means walking that path once, then reading off whatever hangs beneath it. Finding the path costs only as much as the prefix is long, never more, no matter how many words you have stored; you will measure in Step 6 how that plays out once you also have to collect the matches sitting beneath it.

In this tutorial you will build a trie from scratch in plain Python: no dependencies beyond the standard library. You will hit two real bugs along the way, both reproduced with actual code and actual wrong output, not described secondhand: a false-positive search bug from a missing “this is where a word ends” marker, and a data-corruption bug in a naive delete operation that silently destroys an unrelated word sharing a prefix with the one you meant to remove. You will also measure, with a real 50,000-entry benchmark, exactly how much a trie buys you (and where it does not help much at all).

What You Will Build

By the end of this tutorial you will have a complete, tested trie.py module that supports:

  • Inserting words and checking whether an exact word is stored (search)
  • Checking whether any stored word begins with a given prefix (starts_with)
  • Autocomplete: listing every stored word that begins with a prefix
  • Ranking those autocomplete results by how often users actually pick them, not by insertion order
  • Safely deleting a word without corrupting any other word that happens to share a prefix with it

You will also have a ten-test pytest suite that locks in both of the real bugs this tutorial reproduces, so a future change that reintroduces either one fails loudly instead of shipping quietly.

Prerequisites

  • Python 3.10 or later. This tutorial was built and verified on Python 3.13.
  • Comfort with basic Python: classes, dictionaries, recursion. No prior knowledge of tries or trees is assumed; every term is defined before it is used.
  • A terminal with pip available. The only third-party package used is pytest, for the test suite.
python -m venv venv
venv\Scripts\activate   # on Windows
# source venv/bin/activate   # on macOS/Linux

pip install pytest

This tutorial used pytest 9.1.1.

Step 1: Why a Plain List Does Not Scale

Start with the obvious approach, so the trie’s benefit later has something concrete to be measured against. Given a list of product names, find every one that starts with a given prefix:

# step1_naive_list.py
PRODUCTS = [
    "wireless mouse",
    "wireless keyboard",
    "wireless charger",
    "wired headphones",
    "usb-c cable",
    "usb-c hub",
    "webcam 1080p",
]

def naive_prefix_search(catalog, prefix):
    """Return every catalog entry that starts with prefix."""
    return [item for item in catalog if item.startswith(prefix)]

for query in ("wireless", "usb-c", "web"):
    matches = naive_prefix_search(PRODUCTS, query)
    print(f"{query!r} -> {matches}")
'wireless' -> ['wireless mouse', 'wireless keyboard', 'wireless charger']
'usb-c' -> ['usb-c cable', 'usb-c hub']
'web' -> ['webcam 1080p']

This is correct, and for seven products it is instant. The problem is what it costs as the catalog grows: every single call has to look at every single item, checking up to the full length of the prefix against each one. Double the catalog size and this roughly doubles in cost, no matter how specific your prefix is. You will measure exactly how much this matters, with real numbers, in Step 7.

Step 2: A First Trie, With a Bug Baked In

A trie is built from nodes, where each node holds a dictionary mapping a single character to the next node in that character’s path. Here is a first version. It has a real bug in it on purpose, because the bug it contains is the single most common mistake people make the first time they build a trie, and seeing it fail is more instructive than being told to avoid it.

# step2_naive_trie_bug.py
class TrieNode:
    def __init__(self):
        self.children = {}

class BuggyTrie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())

    def search(self, word):
        """Bug: this only checks that a path exists, not that it ends a word."""
        node = self.root
        for char in word:
            if char not in node.children:
                return False
            node = node.children[char]
        return True

trie = BuggyTrie()
trie.insert("card")

print("insert('card')")
print("search('card') ->", trie.search("card"))
print("search('car')  ->", trie.search("car"))
print("search('ca')   ->", trie.search("ca"))
print("search('cart') ->", trie.search("cart"))
insert('card')
search('card') -> True
search('car')  -> True
search('ca')   -> True
search('cart') -> False

Only the word “card” was ever inserted. Yet search('car') and search('ca') both come back True. Walk through why: insert creates a chain of nodes, one per character, c to a to r to d. search just walks that same chain and returns True the moment it reaches the end of the string you asked about, whether or not that string was ever actually inserted as a complete word. Since “car” and “ca” both happen to be prefixes of a path that exists, they falsely report as found. A trie without a way to mark “a real word ends here” cannot tell the difference between a word and a mere prefix of a longer word.

Step 3: Fix It With an End-of-Word Marker

The fix is a single boolean flag per node, set only when a word actually terminates at that node:

# step3_fixed_trie.py
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_end = True

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.is_end

    def starts_with(self, prefix):
        """True if any stored word begins with prefix (prefix itself need not be a word)."""
        return self._walk(prefix) is not None

    def _walk(self, text):
        node = self.root
        for char in text:
            if char not in node.children:
                return None
            node = node.children[char]
        return node

trie = Trie()
trie.insert("card")

print("insert('card')")
print("search('card')      ->", trie.search("card"))
print("search('car')       ->", trie.search("car"))
print("search('ca')        ->", trie.search("ca"))
print("starts_with('car')  ->", trie.starts_with("car"))
print("starts_with('cart') ->", trie.starts_with("cart"))
insert('card')
search('card')      -> True
search('car')       -> False
search('ca')        -> False
starts_with('car')  -> True
starts_with('cart') -> False

search is now correct: it only returns True for words that were actually inserted. Notice the deliberate split from starts_with, which returns True for “car” even though “car” alone was never inserted, because it exists as a valid prefix of “card”. Autocomplete needs exactly this distinction: a user typing “car” should see “card” suggested, even though “car” by itself is not a word in the dictionary.

Step 4: Turn It Into Autocomplete

Autocomplete means: given a prefix, find every complete word stored beneath it. Walk to the node for the prefix, the same way starts_with does, then do a depth-first search from there, collecting every node along the way whose is_end flag is set.

# step4_autocomplete.py
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False

class AutocompleteTrie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_end = True

    def _walk(self, text):
        node = self.root
        for char in text:
            if char not in node.children:
                return None
            node = node.children[char]
        return node

    def autocomplete(self, prefix):
        """Return every stored word that starts with prefix."""
        start_node = self._walk(prefix)
        if start_node is None:
            return []
        results = []
        self._collect(start_node, prefix, results)
        return results

    def _collect(self, node, path, results):
        if node.is_end:
            results.append(path)
        for char, child in node.children.items():
            self._collect(child, path + char, results)

PRODUCTS = [
    "wireless mouse", "wireless keyboard", "wireless charger",
    "wired headphones", "usb-c cable", "usb-c hub", "webcam 1080p",
]

trie = AutocompleteTrie()
for product in PRODUCTS:
    trie.insert(product)

for query in ("wireless", "usb-c", "web"):
    print(f"autocomplete({query!r}) -> {trie.autocomplete(query)}")

print()
print("case-sensitivity gotcha:")
trie.insert("Apple Watch")
print("autocomplete('Apple') ->", trie.autocomplete("Apple"))
print("autocomplete('apple') ->", trie.autocomplete("apple"))
autocomplete('wireless') -> ['wireless mouse', 'wireless keyboard', 'wireless charger']
autocomplete('usb-c') -> ['usb-c cable', 'usb-c hub']
autocomplete('web') -> ['webcam 1080p']

case-sensitivity gotcha:
autocomplete('Apple') -> ['Apple Watch']
autocomplete('apple') -> []

The autocomplete results are correct. The last two lines demonstrate a gotcha worth calling out explicitly: this trie is case-sensitive, because “A” and “a” are simply two different dictionary keys as far as children is concerned. That is not a bug so much as an unannounced design decision. A real search box almost always wants case-insensitive matching, and the fix is cheap: normalize every string to lowercase (or apply casefold() for broader Unicode correctness) both when you insert and when you query, and keep the original-cased string as a stored value if you still need to display it with its real capitalization.

Step 5: Rank Suggestions by What People Actually Pick

Plain autocomplete has a real usability problem once a prefix matches more than a handful of words: it returns them in whatever order the tree’s internal dictionaries happen to produce, which has nothing to do with which suggestion the user is likely to want. Fix this by tracking, per word, how many times it has actually been chosen, and sorting by that count.

# step5_ranked_autocomplete.py
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False
        self.frequency = 0

class RankedTrie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_end = True

    def record_search(self, word):
        """Call this every time a user actually picks `word` from the results."""
        node = self._walk(word)
        if node is None or not node.is_end:
            raise ValueError(f"{word!r} was never inserted")
        node.frequency += 1

    def _walk(self, text):
        node = self.root
        for char in text:
            if char not in node.children:
                return None
            node = node.children[char]
        return node

    def autocomplete(self, prefix, limit=5):
        start_node = self._walk(prefix)
        if start_node is None:
            return []
        matches = []
        self._collect(start_node, prefix, matches)
        matches.sort(key=lambda pair: (-pair[1], pair[0]))
        return [word for word, _freq in matches[:limit]]

    def _collect(self, node, path, matches):
        if node.is_end:
            matches.append((path, node.frequency))
        for char, child in node.children.items():
            self._collect(child, path + char, matches)

PRODUCTS = [
    "wireless mouse", "wireless keyboard", "wireless charger",
    "wireless earbuds", "wireless router",
]

trie = RankedTrie()
for product in PRODUCTS:
    trie.insert(product)

print("no searches recorded yet, tie-broken alphabetically:")
print(trie.autocomplete("wireless", limit=3))

for _ in range(50):
    trie.record_search("wireless earbuds")
for _ in range(20):
    trie.record_search("wireless charger")
for _ in range(5):
    trie.record_search("wireless mouse")

print()
print("after 50/20/5 real searches, ranked by popularity:")
print(trie.autocomplete("wireless", limit=3))
no searches recorded yet, tie-broken alphabetically:
['wireless charger', 'wireless earbuds', 'wireless keyboard']

after 50/20/5 real searches, ranked by popularity:
['wireless earbuds', 'wireless charger', 'wireless mouse']

Before any searches are recorded, results fall back to alphabetical order, which is a sane, predictable default. Once real usage accumulates, “wireless earbuds” (50 picks) correctly jumps ahead of “wireless keyboard” (0 picks), even though “keyboard” sorts earlier in the alphabet. The sort key (-pair[1], pair[0]) is doing two things at once: the negated frequency sorts highest-first, and the word itself as a tiebreaker keeps output deterministic when two words have identical frequency, which matters for reproducible tests.

Step 6: Benchmark the Trie Against the Naive List at Scale

Now measure the actual payoff. Simulate a more realistic scenario: an internal admin tool’s search box, backed by 50,000 resource identifiers like user_1049283 or invoice_0021455, and compare a trie-based autocomplete against the plain list scan from Step 1.

# step6_benchmark.py
import random
import time

class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_end = True

    def autocomplete(self, prefix):
        node = self.root
        for char in prefix:
            if char not in node.children:
                return []
            node = node.children[char]
        results = []
        self._collect(node, prefix, results)
        return results

    def _collect(self, node, path, results):
        if node.is_end:
            results.append(path)
        for char, child in node.children.items():
            self._collect(child, path + char, results)

def build_corpus(size, seed=42):
    """Simulate resource identifiers from an internal admin search box."""
    rng = random.Random(seed)
    resource_types = ["user", "order", "invoice", "session", "customer", "ticket"]
    words = []
    for _ in range(size):
        prefix = rng.choice(resource_types)
        suffix = "".join(rng.choice("0123456789") for _ in range(7))
        words.append(f"{prefix}_{suffix}")
    return words

def naive_prefix_search(catalog, prefix):
    return [item for item in catalog if item.startswith(prefix)]

corpus = build_corpus(50_000)
trie = Trie()
for word in corpus:
    trie.insert(word)

queries = ["user_10", "order_5", "invoice_999", "customer_42"]
print(f"corpus size: {len(corpus):,} identifiers")
print()

for query in queries:
    start = time.perf_counter()
    naive_result = naive_prefix_search(corpus, query)
    naive_elapsed = time.perf_counter() - start

    start = time.perf_counter()
    trie_result = trie.autocomplete(query)
    trie_elapsed = time.perf_counter() - start

    assert sorted(naive_result) == sorted(trie_result), "results diverged!"

    speedup = naive_elapsed / trie_elapsed if trie_elapsed > 0 else float("inf")
    print(
        f"prefix={query!r:16} matches={len(naive_result):5}  "
        f"naive={naive_elapsed * 1000:8.3f}ms  trie={trie_elapsed * 1000:8.3f}ms  "
        f"speedup={speedup:6.1f}x"
    )

The first time I ran this, it did not print a clean table. It crashed:

AssertionError: results diverged!

That assertion exists specifically to catch a trie implementation that silently returns the wrong answer, and it caught a real bug in the very code shown above (in an earlier draft). The bug was in autocomplete: the original version had two separate for char in prefix loops, one to validate the path exists and a second to actually walk it, and the validation loop never updated node. It kept re-checking the root’s own immediate children on every character instead of descending the tree, so any prefix longer than one character that did not happen to have each of its individual letters appear as a first letter somewhere in the corpus was incorrectly reported as “not found.” Printing the buggy version’s actual behavior makes the failure concrete:

naive user_10 count: 80
buggy trie user_10 count: 0

Eighty real matches for “user_10”, and the buggy trie found none of them, because “e” (the second character of “user_10”) is not the first letter of any resource type in the corpus, so the flawed check bailed out immediately. The fix, shown in the code above, is to fold both loops into one: check membership and advance node together, on every character, so the walk actually descends instead of repeatedly inspecting the root. With that fix in place, the benchmark runs cleanly:

corpus size: 50,000 identifiers

prefix='user_10'        matches=   80  naive=   0.718ms  trie=   0.062ms  speedup=  11.5x
prefix='order_5'        matches=  819  naive=   0.660ms  trie=   0.526ms  speedup=   1.3x
prefix='invoice_999'    matches=    8  naive=   0.630ms  trie=   0.009ms  speedup=  68.5x
prefix='customer_42'    matches=   83  naive=   0.624ms  trie=   0.056ms  speedup=  11.2x

This is worth reading carefully rather than skimming for the biggest number. Notice that the win is not uniform. “invoice_999” is a narrow, specific prefix with only 8 matches, and the trie wins by nearly 69 times, because it walks straight to the right subtree and barely has anything to collect. “order_5” is a broad prefix with 819 matches, and the trie’s advantage shrinks to just 1.3 times, because once the walk reaches the right subtree, it still has to visit every single matching node to collect all 819 results, and that collection cost is roughly the same amount of work the naive scan was already doing. The naive list scan pays a fixed cost proportional to the whole corpus on every call, no matter how narrow or broad the prefix is. The trie pays a cost proportional to the prefix length to find the right subtree, plus a cost proportional to the number of matches to collect them. A trie’s real advantage is biggest exactly when you need it most: narrow, specific, autocomplete-style prefixes with a handful of matches, which is the common case for a search box as someone is actively typing.

Step 7: Delete Without Corrupting a Sibling Word

Insertion and lookup are the easy operations. Deletion is where tries get genuinely tricky, because multiple words can share the same nodes, and removing one word’s nodes carelessly can break a completely different word that happens to share a prefix with it. Here is a naive delete that gets this wrong, reproduced with real, corrupted output:

# step7_naive_delete_bug.py
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_end = True

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.is_end

    def _walk(self, text):
        node = self.root
        for char in text:
            if char not in node.children:
                return None
            node = node.children[char]
        return node

    def delete(self, word):
        """Bug: removes every node on the path, even ones another word depends on."""
        node = self.root
        path = [node]
        for char in word:
            node = node.children[char]
            path.append(node)
        node.is_end = False
        # Walk back to front, unconditionally unlinking each node from its parent.
        for i in range(len(word), 0, -1):
            parent = path[i - 1]
            char = word[i - 1]
            del parent.children[char]

trie = Trie()
trie.insert("car")
trie.insert("care")

print("insert('car'), insert('care')")
print("search('car')  ->", trie.search("car"))
print("search('care') ->", trie.search("care"))

trie.delete("car")

print()
print("after delete('car'):")
print("search('car')  ->", trie.search("car"))
print("search('care') ->", trie.search("care"), "  (should still be True)")
insert('car'), insert('care')
search('car')  -> True
search('care') -> True

after delete('car'):
search('car')  -> False
search('care') -> False   (should still be True)

Deleting “car” also silently deleted “care”, which was never asked for. The bug: delete walks back over every node on the “car” path and unconditionally unlinks each one from its parent, including the node for the second “r”, which “care” is still standing on top of (its own final “e” node hangs off that same “r” node). Nothing raised an exception. Nothing warned that another word depended on that node. It just quietly disappeared.

Step 8: Fix Deletion So Nothing Is Removed That Something Else Needs

The correct rule: only unlink a node from its parent if nothing else in the trie still needs it, meaning the node has no children of its own, and the node is not itself marking the end of some other, shorter word. Walk backward from the deleted word’s last node toward the root, and stop pruning the instant you hit a node that must be kept.

# step8_fixed_delete.py
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_end = True

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.is_end

    def _walk(self, text):
        node = self.root
        for char in text:
            if char not in node.children:
                return None
            node = node.children[char]
        return node

    def delete(self, word):
        if not self.search(word):
            raise KeyError(f"{word!r} is not stored in this trie")

        node = self.root
        path = [node]
        for char in word:
            node = node.children[char]
            path.append(node)

        path[-1].is_end = False

        # Walk back to front, but only unlink a node if nothing else needs it:
        # no children of its own, and it isn't itself the end of a shorter word.
        for i in range(len(word), 0, -1):
            current = path[i]
            if current.children or current.is_end:
                break
            parent = path[i - 1]
            char = word[i - 1]
            del parent.children[char]

trie = Trie()
for word in ("car", "care", "cart"):
    trie.insert(word)

print("insert('car'), insert('care'), insert('cart')")
for word in ("car", "care", "cart"):
    print(f"search({word!r}) -> {trie.search(word)}")

trie.delete("car")

print()
print("after delete('car'):")
for word in ("car", "care", "cart"):
    print(f"search({word!r}) -> {trie.search(word)}")

print()
print("deleting a word that was never inserted raises, instead of corrupting anything:")
try:
    trie.delete("carbon")
except KeyError as exc:
    print(f"KeyError: {exc}")
insert('car'), insert('care'), insert('cart')
search('car') -> True
search('care') -> True
search('cart') -> True

after delete('car'):
search('car') -> False
search('care') -> True
search('cart') -> True

deleting a word that was never inserted raises, instead of corrupting anything:
KeyError: "'carbon' is not stored in this trie"

Deleting “car” now leaves “care” and “cart” fully intact. It also guards against a second mistake: attempting to delete a word that was never inserted now raises a clear KeyError up front, via a search check before any mutation happens, rather than the naive version’s behavior of either silently doing nothing useful or crashing partway through with a confusing raw KeyError from inside the walk itself.

Putting It All Together

Here is the complete, consolidated module combining every fix above: end-of-word marking, prefix checks, ranked autocomplete, and safe deletion.

# trie.py
"""A from-scratch trie (prefix tree) with ranked autocomplete and safe deletion."""

class TrieNode:
    __slots__ = ("children", "is_end", "frequency")

    def __init__(self):
        self.children = {}
        self.is_end = False
        self.frequency = 0

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.children.setdefault(char, TrieNode())
        node.is_end = True

    def search(self, word):
        node = self._walk(word)
        return node is not None and node.is_end

    def starts_with(self, prefix):
        return self._walk(prefix) is not None

    def record_search(self, word):
        node = self._walk(word)
        if node is None or not node.is_end:
            raise KeyError(f"{word!r} is not stored in this trie")
        node.frequency += 1

    def autocomplete(self, prefix, limit=5):
        start_node = self._walk(prefix)
        if start_node is None:
            return []
        matches = []
        self._collect(start_node, prefix, matches)
        matches.sort(key=lambda pair: (-pair[1], pair[0]))
        return [word for word, _freq in matches[:limit]]

    def delete(self, word):
        if not self.search(word):
            raise KeyError(f"{word!r} is not stored in this trie")

        node = self.root
        path = [node]
        for char in word:
            node = node.children[char]
            path.append(node)

        path[-1].is_end = False

        for i in range(len(word), 0, -1):
            current = path[i]
            if current.children or current.is_end:
                break
            parent = path[i - 1]
            char = word[i - 1]
            del parent.children[char]

    def _walk(self, text):
        node = self.root
        for char in text:
            if char not in node.children:
                return None
            node = node.children[char]
        return node

    def _collect(self, node, path, matches):
        if node.is_end:
            matches.append((path, node.frequency))
        for char, child in node.children.items():
            self._collect(child, path + char, matches)

TrieNode uses __slots__ here, which is not required for correctness but is worth knowing about: it stops Python from giving each node instance its own dynamic __dict__, which cuts per-node memory overhead. Measuring it directly with sys.getsizeof on this machine: a plain node instance without __slots__ reports 48 bytes for the instance itself, plus a separate 296-byte __dict__ to hold its three attributes, for 344 bytes total. The same node with __slots__ reports 56 bytes total, no separate dictionary at all, roughly a sixfold reduction per node. That matters for a trie specifically, because a trie with a large, diverse vocabulary can allocate a very large number of small node objects, often far more nodes than words.

Now the test suite that locks all of this in, including regression tests for both bugs reproduced above:

# test_trie.py
import pytest
from trie import Trie

def test_search_requires_full_word_not_just_a_path():
    trie = Trie()
    trie.insert("card")
    assert trie.search("card") is True
    assert trie.search("car") is False
    assert trie.search("ca") is False

def test_starts_with_matches_partial_paths():
    trie = Trie()
    trie.insert("card")
    assert trie.starts_with("car") is True
    assert trie.starts_with("cart") is False

def test_search_missing_word_returns_false():
    trie = Trie()
    trie.insert("card")
    assert trie.search("cards") is False
    assert trie.search("zzz") is False

def test_autocomplete_returns_every_stored_word_with_prefix():
    trie = Trie()
    for word in ("wireless mouse", "wireless keyboard", "wired headphones"):
        trie.insert(word)
    assert sorted(trie.autocomplete("wireless")) == [
        "wireless keyboard", "wireless mouse",
    ]
    assert trie.autocomplete("nope") == []

def test_autocomplete_ranks_by_recorded_frequency():
    trie = Trie()
    for word in ("wireless mouse", "wireless keyboard", "wireless charger"):
        trie.insert(word)
    for _ in range(10):
        trie.record_search("wireless charger")
    assert trie.autocomplete("wireless", limit=1) == ["wireless charger"]

def test_record_search_on_unknown_word_raises():
    trie = Trie()
    trie.insert("card")
    with pytest.raises(KeyError):
        trie.record_search("cart")

def test_delete_removes_only_the_target_word():
    trie = Trie()
    for word in ("car", "care", "cart"):
        trie.insert(word)
    trie.delete("car")
    assert trie.search("car") is False
    assert trie.search("care") is True
    assert trie.search("cart") is True

def test_delete_prunes_orphaned_nodes():
    trie = Trie()
    trie.insert("solo")
    trie.delete("solo")
    assert trie.root.children == {}

def test_delete_unknown_word_raises_and_leaves_trie_intact():
    trie = Trie()
    trie.insert("car")
    with pytest.raises(KeyError):
        trie.delete("carbon")
    assert trie.search("car") is True

def test_case_sensitivity_is_exact_match_by_default():
    trie = Trie()
    trie.insert("Apple Watch")
    assert trie.search("Apple Watch") is True
    assert trie.search("apple watch") is False
$ python -m pytest test_trie.py -v
collecting ... collected 10 items

test_trie.py::test_search_requires_full_word_not_just_a_path PASSED
test_trie.py::test_starts_with_matches_partial_paths PASSED
test_trie.py::test_search_missing_word_returns_false PASSED
test_trie.py::test_autocomplete_returns_every_stored_word_with_prefix PASSED
test_trie.py::test_autocomplete_ranks_by_recorded_frequency PASSED
test_trie.py::test_record_search_on_unknown_word_raises PASSED
test_trie.py::test_delete_removes_only_the_target_word PASSED
test_trie.py::test_delete_prunes_orphaned_nodes PASSED
test_trie.py::test_delete_unknown_word_raises_and_leaves_trie_intact PASSED
test_trie.py::test_case_sensitivity_is_exact_match_by_default PASSED

10 passed in 0.02s

The test named test_delete_prunes_orphaned_nodes is worth a second look: it inserts a single, unshared word, deletes it, and then asserts that the root’s children dictionary is completely empty afterward. That test would fail against the naive delete from Step 7 too, but for a different reason than the corruption bug: a delete implementation that only clears is_end flags and never actually removes any nodes would pass every search-based test while silently leaking memory forever, one orphaned node chain per deleted word. Testing the tree’s actual shape, not just what search reports, is what catches that class of bug.

Why This Matters Beyond Autocomplete: Trie in Your Own Router

Tries were independently described twice in computing history: first by René de la Briandais in 1959, then in 1960 by Edward Fredkin, who coined the name itself. According to Wikipedia’s Trie article, Fredkin pronounced it “tree,” “after the middle syllable of retrieval,” even though many people today pronounce it “try” specifically to avoid confusing it verbally with an ordinary tree. The same article notes that tries “are particularly effective for tasks such as autocomplete, spell checking, and IP routing, offering advantages over hash tables due to their prefix-based organization and lack of hash collisions,” and specifically that “searching for a node with an associated key of size m has the complexity of O(m),” independent of how many total keys are stored, unlike a hash table’s worst-case behavior.

The IP routing mention is not a throwaway example. Every time a packet crosses a router, the router has to find the most specific matching entry in its routing table for that packet’s destination address, a problem called longest prefix match. Wikipedia’s own Internet routing section states plainly: “Compressed variants of tries, such as databases for managing Forwarding Information Base (FIB), are used in storing IP address prefixes within routers and bridges for prefix-based lookup to resolve mask-based operations in IP routing.” This is not a historical curiosity. The Linux kernel’s own networking documentation describes exactly this mechanism as currently implemented: an LC-trie (level-compressed trie) backing the kernel’s Forwarding Information Base. Its lookup notes describe the same core algorithm you just built, extended to handle “no exact match” gracefully: “We descend the trie, key segment by key segment, until we find a leaf… If we don’t find a match, we enter prefix matching mode. The prefix length, starting out at the same as the key length, is reduced one step at a time, and we backtrack upwards through the trie trying to find a longest matching prefix.” Every time a Linux machine forwards a packet, it is walking a trie in essentially the same shape as the one you built in this tutorial, just keyed on the bits of an IP address instead of the characters of a product name.

Common Mistakes and How to Verify Each Step Worked

  • Forgetting the end-of-word marker. If search("car") returns True after only ever inserting “card”, you have Step 2’s bug. Verify by inserting one long word and searching for each of its proper prefixes; every one of them should return False from search (though True from starts_with).
  • Assuming case-insensitivity for free. A trie treats “A” and “a” as unrelated keys unless you normalize case yourself, on both insert and lookup. Verify by inserting one mixed-case word and searching for it in a different case; if you want case-insensitive behavior and get False, add normalization.
  • Deleting without checking what else depends on a node. If deleting one word makes a completely different, unrelated word stop being found, you have Step 7’s corruption bug. Verify with a three-line reproduction: insert two words where one is a prefix of the other (like “car” and “care”), delete the shorter one, and confirm the longer one still searches as True.
  • Testing only through search, never checking the tree’s actual shape. A delete that clears flags but never removes nodes passes every naive test while leaking memory forever. Verify by deleting every word you inserted and asserting the root’s children dictionary ends up empty.
  • Assuming the trie always wins. As Step 6 showed, a broad prefix with hundreds of matches barely beats a linear scan, because collecting many results costs roughly the same either way. A trie’s biggest win is on narrow, specific prefixes with few matches, which is the common case while a user is still typing.

Confirming Everything Works End-to-End

Run the full test suite one more time from a clean checkout of just trie.py and test_trie.py, with no other files present, to confirm there is no hidden dependency on anything left over from the earlier, buggy steps:

$ python -m pytest test_trie.py -v
...
10 passed in 0.06s

If all ten tests pass on a fresh checkout, you have a trie that correctly distinguishes prefixes from real words, supports ranked autocomplete driven by real usage, and deletes words without corrupting anything that shares a prefix with them, the three properties this tutorial set out to build and break on purpose along the way.

Next Steps

If you found the “build a classic data structure from scratch, then break it on purpose” format useful, two more sxz.io Learning Hub tutorials follow the same pattern with different structures and different real bugs: an LRU cache built from a doubly linked list and a hash map, and a Bloom filter that trades a small, tunable false-positive rate for constant-size membership checks. Both reproduce their own real, working code and their own real bugs the same way this one did.

Tags:

AlgorithmsAutocompleteData StructuresPythonTrie

Share

Five sample state-issued EBT benefit cards fanned out on a white background
Previous Post

AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
26 Sep
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
26 Sep
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
Trending
September 26, 2026
How to Build a Trie From Scratch in Python for Fast Prefix Search and Autocomplete
September 26, 2026
AI-Made Fake Cards Turn an Old Mail Scam Into a Growing Fraud Wave
September 26, 2026
OpenAI Pauses Training of Its Most Capable Models for the Second Time in Three Months
September 26, 2026
How to Verify Cloudflare Turnstile Tokens Server-Side in a Python App
September 26, 2026
TU Graz’s File Notification Attacks Turn a Decades-Old OS Feature Into a Side Channel
September 26, 2026
Anthropic’s $11.6 Billion Akamai Deal Flips the Usual AI Financing Script

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026