Guide
Build a Trie in Python: A Practical Guide
Learn how to implement a trie (prefix tree) from scratch in Python, with insert, search, and prefix matching operations—plus real-world use cases.
- Published
- 21 August 2026
- Reading time
- 6 min.
- Bureau
- Hong Kong
The trie—pronounced “try” (or sometimes “tree” if you’re feeling rebellious)—is one of those data structures that feels esoteric until you finally have a genuinely good excuse to use it. Then, suddenly, it’s the cleanest tool for the job.
If you’ve ever built an autocomplete feature, spell checker, or IP router table, you’ve likely hit the moment where a hash map alone doesn’t cut it. That’s where the trie steps in.
What Is a Trie?
A trie is a tree-like data structure that stores strings character by character, sharing common prefixes. Each node represents a single character, and the path from the root to any node spells out a prefix. A full word is marked with a special flag.
Here’s what that looks like conceptually for the words "cat", "car", and "card":
(root)
/ \
c ...
/
a
/ \
t r
|
d
Note how "car" and "card" share the c → a → r path. That’s the entire point: common prefixes are stored once, not repeated.
Why Use a Trie?
- Fast prefix lookups: Check if any word starts with
"car"in O(m) time, where m is the length of the prefix—independent of how many words you’ve stored. - Memory efficient for overlapping prefixes (e.g.,
"cat","cats","catnip"). - Ordered iteration if you traverse depth-first.
But there are costs: tries use more memory per node than a flat list of strings, and they can be slower for exact-match lookups compared to a well-tuned hash map.
The Node
We’ll build this from scratch. No libraries, no magic. Just Python and a bit of cleverness.
Start with a single node class:
class TrieNode:
def __init__(self):
# Each child is a character -> TrieNode mapping
self.children = {}
# True if a word ends exactly at this node
self.is_end_of_word = False
Using a Python dict for children keeps things simple and fast. Each node only allocates space for characters it actually has children for.
The Trie Class
Now the main class with our core operations:
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end_of_word = True
def search(self, word: str) -> bool:
node = self._traverse(word)
return node is not None and node.is_end_of_word
def starts_with(self, prefix: str) -> bool:
return self._traverse(prefix) is not None
def _traverse(self, prefix: str):
"""Walk down the trie following `prefix`. Return the final node, or None."""
node = self.root
for char in prefix:
if char not in node.children:
return None
node = node.children[char]
return node
The key insight: search() and starts_with() share almost all their logic. The only difference is whether we also require the final node to be marked as the end of a word.
Collecting All Words with a Prefix
The real power of a trie shows when you want not just whether a prefix exists, but which words match it. That’s how autocomplete works.
class Trie:
# ... (previous methods)
def words_with_prefix(self, prefix: str) -> list[str]:
node = self._traverse(prefix)
if node is None:
return []
return self._collect(node, prefix)
def _collect(self, node: TrieNode, path: str) -> list[str]:
results = []
if node.is_end_of_word:
results.append(path)
for char, child in node.children.items():
results.extend(self._collect(child, path + char))
return results
This recursive collector walks every descendant of a given node, building up strings along the way. With c, ca, car, card, and cart in the trie:
trie.words_with_prefix("ca")
# ['car', 'card', 'cart']
Deleting Words
Deletion is the trickiest operation, because you can’t just remove a node if other words share its path. You have to delete from the leaf upward, stopping as soon as a node still matters.
class Trie:
# ... (previous methods)
def delete(self, word: str) -> bool:
"""Return True if the word existed and was removed."""
def _delete(node: TrieNode, word: str, depth: int) -> bool:
if depth == len(word):
if not node.is_end_of_word:
return False # word not present
node.is_end_of_word = False
return len(node.children) == 0 # safe to prune this node
char = word[depth]
child = node.children.get(char)
if child is None:
return False
should_delete_child = _delete(child, word, depth + 1)
if should_delete_child:
del node.children[char]
# Also return True to propagate upward if this node has no other children
return not node.is_end_of_word and len(node.children) == 0
return False
return _delete(self.root, word, 0)
The recursion returns a boolean: “should the caller remove me?” A node is safe to remove only if it’s not the end of a word itself and has no remaining children.
Putting It All Together
Here’s the complete, self-contained implementation:
class TrieNode:
def __init__(self):
self.children = {}
self.is_end_of_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.is_end_of_word = True
def search(self, word: str) -> bool:
node = self._traverse(word)
return node is not None and node.is_end_of_word
def starts_with(self, prefix: str) -> bool:
return self._traverse(prefix) is not None
def words_with_prefix(self, prefix: str) -> list[str]:
node = self._traverse(prefix)
if node is None:
return []
return self._collect(node, prefix)
def delete(self, word: str) -> bool:
def _delete(node: TrieNode, word: str, depth: int) -> bool:
if depth == len(word):
if not node.is_end_of_word:
return False
node.is_end_of_word = False
return len(node.children) == 0
char = word[depth]
child = node.children.get(char)
if child is None:
return False
should_delete_child = _delete(child, word, depth + 1)
if should_delete_child:
del node.children[char]
return not node.is_end_of_word and len(node.children) == 0
return False
return _delete(self.root, word, 0)
def _traverse(self, prefix: str):
node = self.root
for char in prefix:
if char not in node.children:
return None
node = node.children[char]
return node
def _collect(self, node: TrieNode, path: str) -> list[str]:
results = []
if node.is_end_of_word:
results.append(path)
for char, child in node.children.items():
results.extend(self._collect(child, path + char))
return results
Complexity at a Glance
| Operation | Time | Space |
|---|---|---|
insert |
O(m) | O(m) worst-case new nodes |
search |
O(m) | O(1) |
starts_with |
O(m) | O(1) |
words_with_prefix |
O(m + k) | O(k) for results |
delete |
O(m) | O(m) recursion stack |
Here, m is the length of the relevant string, and k is the number of matching words in the output.
Real-World Use Cases
- Autocomplete: exactly what
words_with_prefixgives you. - Spell checkers: store the dictionary in a trie, check word existence in O(m).
- IP routing (longest prefix match): binary tries are the classic implementation.
- Word games (Boggle, Scrabble): prune invalid prefixes early during board traversal.
- Phone directory contact search: match by prefix without scanning every contact.
When Not to Use a Trie
A trie is powerful, but it’s not always the right call:
- Tiny datasets: a plain list with
.startswith()filtering is faster to write and fast enough. - Heavy memory constraints: Python dicts per node add real overhead (hundreds of bytes per node). For large dictionaries, a sorted list with
bisector a DAWG (Directed Acyclic Word Graph) may be better. - Exact-match-only workloads: a hash set wins on speed and simplicity.
Next Steps
Want to push this further? Try these experiments:
- Limit results in
words_with_prefixso autocomplete doesn’t return 10,000 matches. - Sort results by frequency or a custom score.
- Implement a compact trie (radix tree) to reduce memory by merging nodes with single children.
- Add case-insensitivity by normalizing characters to lowercase on insert and search.
The trie looks intimidating on paper, but once you’ve written one yourself, it stops being a mysterious black box and becomes just another tool in your toolkit. And now you have a working, tested implementation ready to drop into your next project.
- python
- data-structures
- algorithms
- trie