django-icv-tree
Hierarchical data in Django without the complexity. django-icv-tree stores
tree structures as materialised paths: every node knows its full ancestry in a
single indexed column, so ancestor, descendant, and sibling queries are fast
prefix lookups rather than recursive joins or nested set bookkeeping.
One abstract model, one manager, one queryset. Every traversal method returns a
lazy QuerySet: no Python list coercions, no surprise N+1 queries. Configurable
path format, async-safe, zero tenancy coupling.
Replaces django-mptt, django-treebeard (materialised path), and django-polymorphic-tree with a simpler, single-file API.
pip install django-icv-tree
Quick start
# models.py
from django.db import models
from icv_tree.models import TreeNode
class Category(TreeNode):
name = models.CharField(max_length=255)
def __str__(self):
return self.name
# settings.py
INSTALLED_APPS = [
# ...
"icv_tree",
"myapp",
]
python manage.py makemigrations myapp
python manage.py migrate
root = Category(name="Electronics", parent=None)
root.save() # path="0001", depth=0, order=0
phones = Category(name="Phones", parent=root)
phones.save() # path="0001/0001", depth=1, order=0
cases = Category(name="Cases", parent=phones)
cases.save() # path="0001/0001/0001", depth=2, order=0
Path, depth, and order are computed automatically on save; you never set them manually.
Traversal
Every method returns a lazy QuerySet that you can filter, slice, and chain:
# Instance methods
node.get_ancestors() # root -> ... -> parent, ordered by depth
node.get_ancestors(include_self=True)
node.get_descendants() # depth-first, ordered by path
node.get_descendants(include_self=True)
node.get_children() # direct children, ordered by sibling order
node.get_siblings() # same parent, excluding self
node.get_siblings(include_self=True)
node.get_root() # root of this node's tree
node.get_descendant_count() # COUNT query
node.is_root() # bool, no DB hit
node.is_leaf() # bool, EXISTS query
Manager and QuerySet methods
The same traversal is available on the manager and as chainable queryset filters:
# Manager
Category.objects.roots() # all root nodes
Category.objects.at_depth(2) # all nodes at depth 2
Category.objects.ancestors_of(node)
Category.objects.descendants_of(node)
Category.objects.children_of(node)
Category.objects.siblings_of(node)
# QuerySet: chain with any Django filter
Category.objects.descendants_of(node).filter(is_active=True)
Category.objects.with_tree_fields() # annotates is_root, child_count
Moving nodes
from icv_tree.services import move_to
move_to(node, target, position="last-child")
# or
node.move_to(target, position="first-child")
Positions: first-child, last-child, left, right.
Moves are atomic (transaction.atomic), recompute paths for the entire subtree,
and reorder siblings at both source and destination. A node_moved signal is
emitted after commit.
Cycle detection prevents moving a node under its own descendant.
Reordering a subset of siblings
move_to changes how many slots a sibling list has (it inserts into, or
removes a node from, the list). For the common case of reordering rows that
already share a parent, reorder_siblings is a narrower, cheaper primitive
that never adds or removes a slot:
from icv_tree.services import reorder_siblings
reorder_siblings(Category, ordered_ids=[c3.pk, c1.pk, c2.pk])
ordered_ids names a set of sibling rows (they must all share one parent,
which may be None for roots) and gives the sequence you want them in. The
listed rows are permuted across the (order, path) slots they already
occupy: the current slots are collected and sorted, then handed out to the
rows in the order you gave. Every sibling that is not listed, including
other siblings of the same parent interleaved between the listed ones, is
left completely untouched, its path, depth, and order are byte-for-byte
unchanged.
This is what lets a consumer reorder only the rows it owns within a shared
sibling list, for example a root sibling list that spans several
tree_scope_field values, without touching any other scope's roots and
without a rebuild.
reorder_siblings is atomic, raises TreeStructureError for an empty or
duplicate id list, an unknown id, or ids that do not all share one parent,
and does not emit a signal (there is no single-node shape for a multi-row
permutation).
Rebuilding
If paths get out of sync (bulk imports, raw SQL, migrations), rebuild from the parent FK adjacency list:
Category.objects.rebuild()
# or
python manage.py icv_tree_rebuild --model=myapp.Category
Options:
--dry-run: report what would change without writing--check: run integrity checks only, exit 1 if issues found--scope: restrict the rebuild to onetree_scope_fieldvalue (see below)
On PostgreSQL with ICV_TREE_ENABLE_CTE = True, rebuild uses a recursive CTE
for better performance on large trees.
Scoped rebuilds
For a model that sets tree_scope_field (see TreeNode's docstring for the
full path-scoping contract), a full rebuild reconstructs every scope in one
pass. To rebuild just one scope's tree, pass scope=:
Term.objects.rebuild(scope=vocabulary)
# or
python manage.py icv_tree_rebuild --model=myapp.Term --scope=5
A scoped rebuild only reads, clears, and writes rows in the given scope.
Every other scope's rows, including their path, depth, and order, are left
completely untouched. This is safe because a scoped model's uniqueness
constraint covers (scope_field, path), not path alone, so a scoped
rebuild's transient placeholder values can never collide with another
scope's real paths.
Passing scope to a model that does not define tree_scope_field raises
ImproperlyConfigured.
Integrity checks
from icv_tree.services import check_tree_integrity
result = check_tree_integrity(Category)
# {
# "orphaned_nodes": [],
# "depth_mismatches": [],
# "path_prefix_violations": [],
# "duplicate_paths": [],
# "total_issues": 0,
# }
Django system checks run automatically at startup:
icv_tree.E001: orphaned nodes (parent references missing row)icv_tree.E002: path inconsistencies (depth mismatch, prefix violation, duplicates)
Models can opt out with check_tree_integrity = False on the class.
Signals
from icv_tree.signals import node_moved, tree_rebuilt
@receiver(node_moved)
def on_move(sender, instance, old_parent, new_parent, old_path, **kwargs):
# Invalidate cache, re-index search, etc.
pass
@receiver(tree_rebuilt)
def on_rebuild(sender, nodes_updated, nodes_unchanged, scope, **kwargs):
# scope is the value rebuild() was restricted to, or None for a full rebuild.
pass
Both signals fire after the transaction commits.
Admin
from django.contrib import admin
from icv_tree.admin import TreeAdmin
@admin.register(Category)
class CategoryAdmin(TreeAdmin, admin.ModelAdmin):
list_display = ["name"]
TreeAdmin provides:
- Indented list display proportional to node depth
- Read-only path, depth, and order fields
- A move endpoint (
POST <pk>/tree-move/) that acceptstarget_idandpositionand callsmove_to(). This is a server-side hook only: the package does not ship any client-side drag-and-drop JavaScript.TreeAdmin.Media.jsis an empty tuple by design, so wiring an actual drag-and-drop UI (SortableJS, jsTree, or your own) that POSTs to this endpoint is the consuming project's responsibility.
Template tags
{% load icv_tree %}
<!-- Recursive tree rendering -->
{% recurse_tree root_nodes %}
<li>
{{ node.name }}
{% if children %}
<ul>
{% recurse_tree children %}
<li>{{ node.name }}</li>
{% end_recurse_tree %}
</ul>
{% endif %}
</li>
{% end_recurse_tree %}
<!-- Breadcrumbs -->
{% tree_breadcrumbs node as crumbs %}
{% for crumb in crumbs %}
<a href="{{ crumb.get_absolute_url }}">{{ crumb }}</a>
{% endfor %}
<!-- Filter: is_ancestor_of -->
{% if node|is_ancestor_of:current_node %}active{% endif %}
Migration operation
For optimal prefix-query performance, add a PathIndex in your migration:
from icv_tree.operations import PathIndex
class Migration(migrations.Migration):
operations = [
migrations.CreateModel(name="Category", fields=[...]),
PathIndex(model_name="category", field_name="path"),
]
On PostgreSQL this creates a text_pattern_ops index for efficient
LIKE 'path/%' queries. On other databases it creates a standard B-tree index.
Testing utilities
Factory base classes
# myapp/factories.py
import factory
from icv_tree.testing.factories import TreeNodeFactory
class CategoryFactory(TreeNodeFactory):
class Meta:
model = Category
name = factory.Sequence(lambda n: f"Category {n}")
# Usage
root = CategoryFactory()
child = CategoryFactory(parent=root)
Test mixin
from icv_tree.testing import TreeTestMixin
class TestCategoryTree(TreeTestMixin, TestCase):
def test_tree_is_valid(self):
self.assert_tree_valid(Category)
def test_ancestry(self):
self.assert_is_ancestor_of(root, child)
self.assert_is_descendant_of(child, root)
def test_build_tree(self):
nodes = self.create_tree_structure(Category, {
"Electronics": {
"Phones": {"Cases": {}},
"Laptops": {},
},
})
assert nodes["Cases"].depth == 2
pytest fixture
# conftest.py
from icv_tree.testing.fixtures import tree_integrity_checker # noqa: F401
# tests
def test_my_tree(tree_integrity_checker):
# ... build tree ...
tree_integrity_checker(Category)
Settings
All settings use the ICV_TREE_* prefix and have sensible defaults:
| Setting | Default | Description |
|---|---|---|
ICV_TREE_PATH_SEPARATOR |
"/" |
Single character separating path segments. Must not be a digit. |
ICV_TREE_STEP_LENGTH |
4 |
Digits per path segment. 4 supports up to 9,999 siblings. Range: 1-10. |
ICV_TREE_MAX_PATH_LENGTH |
255 |
Max CharField length. With defaults: 51 levels deep. |
ICV_TREE_ENABLE_CTE |
False |
Use PostgreSQL recursive CTE for rebuild. No effect on other databases. |
ICV_TREE_REBUILD_BATCH_SIZE |
1000 |
Nodes per bulk_update batch during rebuild. |
ICV_TREE_CHECK_ON_SAVE |
False |
Run path validation on every save. Development only. |
Warning: Changing ICV_TREE_PATH_SEPARATOR or ICV_TREE_STEP_LENGTH after
data exists will invalidate all stored paths. Run rebuild() after changing.
Requirements
- Python 3.11+
- Django 5.1+
Optional: factory-boy for TreeNodeFactory.
Licence
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_icv_tree-1.0.0.tar.gz.
File metadata
- Download URL: django_icv_tree-1.0.0.tar.gz
- Upload date:
- Size: 65.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d6b4108c052ad3cd2f241142a5791c8ffd8c954456078f52222f0e93f9d322cf
|
|
| MD5 |
2e916ec0f0562900a722775aa9471b31
|
|
| BLAKE2b-256 |
743a59eea3bf6dd2bfd2eca3b0bc8a57ee11679c90ad2b5b9b0fa2631b3d49c9
|
Provenance
The following attestation bundles were made for django_icv_tree-1.0.0.tar.gz:
Publisher:
publish.yml on icvoss/django-icv-tree
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_icv_tree-1.0.0.tar.gz -
Subject digest:
d6b4108c052ad3cd2f241142a5791c8ffd8c954456078f52222f0e93f9d322cf - Sigstore transparency entry: 2395111199
- Sigstore integration time:
-
Permalink:
icvoss/django-icv-tree@269a842347e0194011e015b83381f6ffe8125f19 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/icvoss
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@269a842347e0194011e015b83381f6ffe8125f19 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_icv_tree-1.0.0-py3-none-any.whl.
File metadata
- Download URL: django_icv_tree-1.0.0-py3-none-any.whl
- Upload date:
- Size: 46.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c80bbf485806c143bb6a81bd3d81c70cb4079b615ec8f02608280e56431c547
|
|
| MD5 |
174f67360e42e032d26a44bd440a6fd5
|
|
| BLAKE2b-256 |
835c82f6c7beaae3f61d25e7677945e330b8c757ba924dbdfcd1fadbe6fe0f60
|
Provenance
The following attestation bundles were made for django_icv_tree-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on icvoss/django-icv-tree
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_icv_tree-1.0.0-py3-none-any.whl -
Subject digest:
4c80bbf485806c143bb6a81bd3d81c70cb4079b615ec8f02608280e56431c547 - Sigstore transparency entry: 2395111485
- Sigstore integration time:
-
Permalink:
icvoss/django-icv-tree@269a842347e0194011e015b83381f6ffe8125f19 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/icvoss
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@269a842347e0194011e015b83381f6ffe8125f19 -
Trigger Event:
push
-
Statement type: