Hierarchical Navigations in Django (Treebeard)

django python

Hierarchical data—categories, org charts, nested comments—is everywhere. SQL wasn’t designed for it, but django-treebeard handles it elegantly.

The Problem

Naive approach:

class Category(models.Model):
    name = models.CharField(max_length=100)
    parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE)

Getting all descendants requires recursive queries:

def get_descendants(category):
    """N+1 nightmare"""
    descendants = []
    for child in category.category_set.all():
        descendants.append(child)
        descendants.extend(get_descendants(child))
    return descendants

For a tree with 1000 nodes, this is hundreds of queries.

Treebeard’s Approach

django-treebeard implements three algorithms:

AlgorithmReadsWritesBest For
Materialized PathFastFastGeneral use
Nested SetsFastestSlowRead-heavy
Adjacency ListModerateFastWrite-heavy

Setup

pip install django-treebeard
# settings.py
INSTALLED_APPS = [
    ...
    'treebeard',
]
from treebeard.mp_tree import MP_Node

class Category(MP_Node):
    name = models.CharField(max_length=100)
    
    node_order_by = ['name']
    
    def __str__(self):
        return self.name

Each node stores its path:

ID  | Path   | Depth | Name
1   | 0001   | 1     | Electronics
2   | 00010001 | 2   | Laptops
3   | 000100010001 | 3 | Gaming Laptops
4   | 00010002 | 2   | Phones

Basic Operations

# Create root node
electronics = Category.add_root(name='Electronics')

# Add children
laptops = electronics.add_child(name='Laptops')
phones = electronics.add_child(name='Phones')

# Add grandchild
gaming = laptops.add_child(name='Gaming Laptops')

# Get all descendants in ONE query
descendants = electronics.get_descendants()

# Get ancestors
ancestors = gaming.get_ancestors()

# Get siblings
siblings = laptops.get_siblings()

# Get children
children = electronics.get_children()

Efficient Queries

# Get full tree (one query)
tree = Category.get_tree()

# Get tree as nested dict
tree_dict = Category.dump_bulk()

# Check if ancestor
is_ancestor = electronics.is_ancestor_of(gaming)  # True

# Get depth
depth = gaming.get_depth()  # 3

Moving Nodes

# Move node to new parent
gaming.move(phones, pos='first-child')

# Move as sibling
laptops.move(phones, pos='right')

Admin Integration

from django.contrib import admin
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from .models import Category

@admin.register(Category)
class CategoryAdmin(TreeAdmin):
    form = movenodeform_factory(Category)
    list_display = ['name', 'depth']

This gives you a drag-and-drop tree interface.

Building Navigation

# views.py
def navigation(request):
    # Get tree with custom annotation
    categories = Category.get_tree().annotate(
        product_count=Count('products')
    )
    return render(request, 'nav.html', {'categories': categories})
<!-- nav.html -->
<ul class="nav-tree">
{% for category in categories %}
    {% if category.depth == 1 %}
        <li>
            <a href="{% url 'category' category.pk %}">
                {{ category.name }} ({{ category.product_count }})
            </a>
    {% elif category.depth > prev_depth %}
        <ul>
        <li>
            <a href="{% url 'category' category.pk %}">{{ category.name }}</a>
    {% elif category.depth == prev_depth %}
        </li>
        <li>
            <a href="{% url 'category' category.pk %}">{{ category.name }}</a>
    {% else %}
        {% for i in "x"|rjust:depth_diff %}
            </li></ul>
        {% endfor %}
        </li>
        <li>
            <a href="{% url 'category' category.pk %}">{{ category.name }}</a>
    {% endif %}
    {% with category.depth as prev_depth %}{% endwith %}
{% endfor %}
</ul>

Recursive Serialization

# serializers.py
class CategorySerializer(serializers.ModelSerializer):
    children = serializers.SerializerMethodField()
    
    class Meta:
        model = Category
        fields = ['id', 'name', 'children']
    
    def get_children(self, obj):
        children = obj.get_children()
        return CategorySerializer(children, many=True).data

# Or build tree efficiently
def serialize_tree(root):
    """Serialize tree in single traversal."""
    nodes = root.get_descendants()
    nodes_map = {n.pk: {'id': n.pk, 'name': n.name, 'children': []} 
                 for n in nodes}
    nodes_map[root.pk] = {'id': root.pk, 'name': root.name, 'children': []}
    
    for node in nodes:
        parent_path = node.path[:-4]  # Materialized path parent
        parent = next((n for n in nodes if n.path == parent_path), root)
        nodes_map[parent.pk]['children'].append(nodes_map[node.pk])
    
    return nodes_map[root.pk]

Common Patterns

def product_detail(request, pk):
    product = get_object_or_404(Product, pk=pk)
    breadcrumbs = product.category.get_ancestors(include_self=True)
    return render(request, 'product.html', {
        'product': product,
        'breadcrumbs': breadcrumbs
    })
<nav class="breadcrumb">
    <a href="/">Home</a>
    {% for crumb in breadcrumbs %}
        › <a href="{% url 'category' crumb.pk %}">{{ crumb.name }}</a>
    {% endfor %}
</nav>

Category Products with Descendants

def category_products(request, pk):
    category = get_object_or_404(Category, pk=pk)
    
    # Get products from this category and all descendants
    descendant_ids = category.get_descendants().values_list('id', flat=True)
    all_category_ids = list(descendant_ids) + [category.id]
    
    products = Product.objects.filter(category_id__in=all_category_ids)
    
    return render(request, 'products.html', {
        'category': category,
        'products': products
    })

Performance Considerations

Indexing

class Category(MP_Node):
    name = models.CharField(max_length=100, db_index=True)
    
    class Meta:
        indexes = [
            models.Index(fields=['path']),
            models.Index(fields=['depth']),
        ]

Prefetching

# This doesn't work well with tree structures
# Category.objects.prefetch_related('children')

# Instead, get full tree and organize in memory
tree = list(Category.get_tree())

Final Thoughts

Hierarchical data is common. django-treebeard handles it properly:

Use Materialized Path for most cases. Your N+1 problems disappear.


Trees belong in databases, not just file systems.

All posts