Fast and easy tree structures for Django, maintained inside the database.
django-tree solves the same problem as django-treebeard,
django-tree-queries, django-mptt and django-treenode: storing and
querying tree (hierarchy) structures with Django. It does it differently: you add a PathField to an ordinary model
with a self-referencing ForeignKey, and the hierarchy is maintained
by the database — not in your Python code. There is no model, manager
or queryset to subclass; an optional TreeModelMixin only adds convenience
methods (get_descendants(), get_ancestors(), …).
On PostgreSQL the path is maintained by a PL/pgSQL trigger, so bulk
operations, QuerySet.update() and raw SQL all keep the tree consistent. On
SQLite, MySQL and Oracle there is no such trigger, so the path is
computed in Python on the ORM save cycle (save(), delete(),
QuerySet.update(), bulk_create/bulk_update); writes that bypass the ORM
(raw SQL) need a manual
Model.rebuild_paths().
[!NOTE] django-treebeard ships three interchangeable algorithms — MP (materialized path), NS (nested sets) and AL (adjacency list) — shown as separate columns.
| django-tree | treebeard MP | treebeard NS | treebeard AL | django-mptt | django-tree-queries | django-treenode | |
|---|---|---|---|---|---|---|---|
| Works on any Django database | 🟢 PostgreSQL, SQLite, MySQL, Oracle | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 | 🟢 |
| Drop-in (no model/manager subclassing) | 🟢 add one field | 🔴 subclass MP_Node |
🔴 subclass NS_Node |
🔴 subclass AL_Node |
🔴 subclass MPTTModel |
🔴 subclass TreeNode |
🔴 subclass TreeNodeModel |
Build & move with plain parent + save() |
🟢 | 🔴 API | 🔴 API | 🔴 API | 🟢 | 🟢 | 🟢 |
| Several independent trees per model | 🟢 multiple PathFields |
🔴 one hierarchy | 🔴 one hierarchy | 🔴 one hierarchy | 🔴 one hierarchy | 🔴 one hierarchy | 🔴 one hierarchy |
| Maximum number of siblings | 🟢 unlimited | 🟠 1.7 M | 🟢 1 B | 🟢 unlimited | 🟢 1 B | 🟢 unlimited | 🟢 2.1 B |
| Tree kept correct by the database | 🟢 PostgreSQL: SQL trigger |
🔴 SQLite, MySQL, Oracle: in Python | 🔴 in Python | 🔴 in Python | 🔴 in Python | 🔴 in Python | 🟢 FK only, nothing denormalized | 🔴 in Python + cache |
| Survives bulk writes / update() / raw SQL | 🟢 PostgreSQL
🟡 SQLite, MySQL, Oracle: bulk/update() yes, raw SQL no | 🔴 Python API only | 🔴 Python API only | 🔴 Python API only | 🔴 | 🟢 | 🔴 manual resync |
| Tree filters as composable ORM lookups | 🟢 __descendant_of, __child_of | 🟡 manager methods | 🟡 manager methods | 🟡 manager methods | 🟡 manager methods | 🟡 with_tree_fields() | 🟡 cached properties |
| Admin integration | 🔴 form field only | 🟢 drag-and-drop | 🟢 drag-and-drop | 🟢 drag-and-drop | 🟢 drag-and-drop | 🟢 cut/paste | 🟢 |
| Template tags to render trees | 🔴 | 🟡 | 🟡 | 🟡 | 🟢 {% recursetree %} | 🟢 {% recursetree %} | 🟡 |
| Production-ready | 🟢 | 🟢 | 🟢 | 🟢 | 🟡 works, unmaintained | 🟢 | 🟢 |
🟢 yes / good · 🟡 partial or depends on the variant · 🔴 no / poor.
Maximum number of siblings counts how many children a single parent can
hold before the encoding runs out (django-tree uses fractional indexing, so it
never does; treebeard MP caps at alphabetsteplen = 36⁴; the
nested-set ones at the lft/rgt PositiveIntegerField span, ≈1 B;
django-treenode at its PositiveIntegerField order, ≈2.1 B): 🟢 over 100 M · 🟠
over 1 M · 🔴 otherwise.
Absolute latency and disk usage measured on a tree of 3905 rows. Every test
runs on every implementation: those lacking a native method use a simple,
unofficial ORM equivalent, so the whole grid is comparable. Each cell shows the
measurement; below it, the rank in that row (#n) and a marker.
| django-tree | treebeard MP | treebeard NS | treebeard AL | django-mptt | django-tree-queries | django-treenode | |
|---|---|---|---|---|---|---|---|
| Reads · best | 77 µs |
🟢 #6 | 0.5 µs
🟢 #1 👑 | 0.7 µs
🟢 #3 | 10 µs
🟢 #5 | 1.7 µs
🟢 #4 | 75 µs
🟢 #6 | 0.5 µs
🟢 #1 👑 | | Reads · typical | 511 µs
🟢 #4 | 250 µs
🟢 #1 👑 | 393 µs
🟢 #3 | 1.6 ms
🟢 #6 | 300 µs
🟢 #2 | 1.1 ms
🟢 #5 | 1.9 ms
🟢 #7 | | Reads · worst | 72 ms
🟠 #1 👑 | 344 ms
🔴 #3 | 518 ms
🔴 #4 | 853 ms
🔴 #6 | 118 ms
🔴 #2 | 627 ms
🔴 #5 | 5 min
💩 #7 | | Writes · best | 441 µs
🟢 #6 | 235 µs
🟢 #4 | 205 µs
🟢 #3 | 193 µs
🟢 #2 | 307 µs
🟢 #5 | 183 µs
🟢 #1 👑 | 390 ms
🔴 #7 | | Writes · typical | 2.8 ms
🟢 #3 | 5.7 ms
🟠 #4 | 6.1 ms
🟠 #5 | 969 µs
🟢 #1 👑 | 13 ms
🟠 #6 | 1.0 ms
🟢 #2 | 837 ms
🔴 #7 | | Writes · worst | 2.4 s
💩 #3 | 9.0 s
💩 #4 | 21.8 s
💩 #6 | 926 ms
🔴 #2 | 19.0 s
💩 #5 | 829 ms
🔴 #1 👑 | 25 min
💩 #7 | | Storage | 0.91 MB
🟢 #5 | 0.97 MB
🟢 #6 | 0.79 MB
🟢 #3 | 0.57 MB
🟢 #1 👑 | 0.85 MB
🟢 #4 | 0.57 MB
🟢 #1 👑 | 0.98 MB
🟢 #6 |
Two results within 5 % share a rank. Markers use the same thresholds for reads and writes:
See the full benchmark for every test.
In short:
update() and raw SQL stay safe, with balanced
reads and writes — at the cost of being without admin
drag-and-drop or tree-rendering template tags yet. On SQLite, MySQL and Oracle
the path is maintained in Python on the ORM save cycle instead (raw SQL then
needs a manual rebuild).parent FK with recursive
CTEs, so nothing can get out of sync and writes and storage are the cheapest
and it runs on most databases — at the cost of slow, sometimes very slow, reads.Model.rebuild_paths(). MySQL stores the path as VARBINARY(768) and
Oracle as RAW(2000), capping tree depth.Install the package from PyPI:
pip install django-tree
Then add 'tree' to your INSTALLED_APPS.
Add a PathField to a model that has a ForeignKey('self') — typically named
parent — and add TreeModelMixin for the convenience query methods
(get_children(), get_descendants(), …). The mixin order is not important,
as its methods do not clash with Django.
from django.db.models import Model, CharField, ForeignKey, BooleanField
from tree.fields import PathField
from tree.models import TreeModelMixin
class YourModel(Model, TreeModelMixin):
name = CharField(max_length=30)
parent = ForeignKey('self', null=True, blank=True)
path = PathField()
public = BooleanField(default=False)
class Meta:
ordering = ['path']
# Recommended: speeds up child/sibling/level queries.
indexes = [*PathField.get_indexes('yourmodel', 'path')]
Then create a migration that depends on the latest django-tree migration and
adds a CreateTreeTrigger operation — this installs the SQL trigger that keeps
path up to date automatically:
from django.db import migrations
from tree.operations import CreateTreeTrigger
class Migration(migrations.Migration):
dependencies = [
('tree', '0003_tree_functions'),
]
operations = [
CreateTreeTrigger('your_app.YourModel'),
]
Once the trigger exists, the field maintains itself — building and moving nodes
is just parent + save():
root = YourModel.objects.create(name='root')
child = YourModel.objects.create(name='child', parent=root)
root.get_descendants() # QuerySet of every node under `root`
child.get_ancestors() # QuerySet from the root down to `child`'s parent
That's the whole setup. See Usage for the full API, custom child ordering, and adding the trigger to a table that already holds data.
If you have multiple PathFields on the same model, pass the field name as the
path_field argument of the method you call. If your self-referencing key is
not named parent, pass its name to the parent_field argument of
CreateTreeTrigger.
PathField is automatically filled thanks to CreateTreeTrigger,
you don’t need to set, modify, or even see its value once it is installed.
But you can use the Path object it stores or the more convenient
TreeModelMixin to get tree information about the current instance,
or make complex queries on the whole tree structure.
Example to show you most of the possibilities:
obj = YourModel.objects.all()[0]
obj.path.get_level()
obj.get_level() # Shortcut for the previous method, if you use
# `TreeModelMixin`. Same for other object methods below.
obj.is_root()
obj.is_leaf()
obj.get_children()
obj.get_children().filter(public=True)
obj.get_ancestors()
obj.get_ancestors(include_self=True)
obj.get_descendants(include_self=True)
obj.get_siblings()
obj.get_prev_sibling() # Fetches the previous sibling.
obj.get_next_sibling()
# Same as `get_prev_sibling`, except that we get the first public one.
obj.get_prev_siblings().filter(public=True).first()
other = YourModel.objects.all()[1]
obj.is_ancestor_of(other)
obj.is_descendant_of(other, include_self=True)
YourModel.objects.filter_roots()
#
# Advanced usage
# Use the following methods only if you understand exactly what they mean.
#
YourModel.rebuild_paths() # Rebuilds all paths of this field, useful only
# if something is broken, which shouldn’t happen.
YourModel.disable_tree_trigger() # Disables the SQL trigger.
YourModel.enable_tree_trigger() # Restores the SQL trigger.
with YourModel.disabled_tree_trigger():
# What happens inside this context manager is ignored
# by the SQL trigger.
# The trigger is restored after that, even if an error occurred.
pass
[!NOTE] On SQLite, MySQL and Oracle there is no SQL trigger:
disable_tree_trigger()/enable_tree_trigger()toggle the Python maintenance instead, andrebuild_paths()is also how you resync the tree after a write that bypasses the ORM (raw SQL,cursor.execute, …), which those backends cannot intercept. On PostgreSQL the trigger keeps everything consistent on its own, so you never needrebuild_paths()in normal use.
There is also a bunch of less useful lookups and transforms available. They will be documented with examples in the future.
By default the children of a same parent are ordered by primary key. Pass
order_by to PathField to order them differently — for instance by an
explicit position field, falling back to the name:
from django.db.models import (
Model, CharField, ForeignKey, IntegerField, BooleanField)
from tree.fields import PathField
from tree.models import TreeModelMixin
class YourModel(Model, TreeModelMixin):
name = CharField(max_length=30)
parent = ForeignKey('self', null=True, blank=True)
position = IntegerField(default=1)
path = PathField(order_by=['position', 'name'])
public = BooleanField(default=False)
class Meta:
ordering = ['path']
indexes = [*PathField.get_indexes('yourmodel', 'path')]
And the corresponding migration:
from django.db import models, migrations
from tree.operations import CreateTreeTrigger
class Migration(migrations.Migration):
dependencies = [
('tree', '0003_tree_functions'),
]
operations = [
migrations.AddField('YourModel', 'position',
models.IntegerField(default=1)),
CreateTreeTrigger('YourModel'),
]
PathField is always nullable, so existing rows simply start with a NULL
path. Create the trigger, then rebuild the paths from the parent FKs:
```python from django.db import migration
$ claude mcp add django-tree \
-- python -m otcore.mcp_server <graph>