django-mptt: fast tree reads and the write amplification behind them
Utilities for implementing a modified pre-order traversal tree in django.
At a glance
- What is it?
- The library stores pre-computed left, right, level and tree_id values next to every row, which makes ancestor and descendant queries almost free. Its own README now says it is unmaintained and points new projects at recursive CTEs instead.
- Who is it for?
- django-mptt is still a good piece of engineering that still solves a real problem, and the README is unusually honest about why it stopped being the default choice. If you already have it in a production project, the pre-computed columns are working and the practical move is to keep them, pin your Django version, and remember that rebuild() is the escape hatch whenever code bypasses the library.
- Can I use it commercially?
- Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
- Is it still maintained?
- Yes. The repository last received commits 126 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The pre-order trick that makes tree reads nearly free
Modified Preorder Tree Traversal is an old idea with an old tradeoff. Instead of storing a parent pointer and walking up one row at a time, you store four pre-computed columns next to each row: a left value, a right value, a level, and a tree id. Every node owns a numeric interval, and a whole subtree falls inside its parent's interval. Querying descendants becomes a single indexed range scan on the left and right columns rather than a recursive walk.
The README describes the appeal in plain terms, saying the aim is to make retrieval operations very efficient. That is the entire pitch. A category tree with ten levels and a thousand products per branch turns a query that would otherwise be dozens of round trips into one indexed comparison, which is the kind of speedup that makes deeply nested navigation feel instant.
Registration is meant to be undramatic. You add the package to your installed apps and declare a model field for tree structure, and the library adds the columns and the methods for you. What you get on the model side is a set of methods for changing position in the tree, for retrieving ancestors, siblings and descendants, and for counting descendants without loading the rows.
The project dates from the era when this mattered more than it does now. The pyproject requires Python 3.9 or newer, declares support for Django 4.2 through 5.2, and lists classifiers for Python 3.9 through 3.13. It builds with hatchling and pulls in exactly one runtime dependency:
dependencies = [
"django-js-asset",
]A single dependency for a package that rewrites the write path of every model that registers with it is a genuinely small surface, and the pinned Django floor is visible in the requirements file as well:
Django >= 4.2
django-js-assetWhy every write touches far more rows than you expect
Now the other side of the tradeoff, which the README is unusually direct about. Because those numeric intervals are positional, inserting a node in the middle of a tree means renumbering every sibling after it and shifting the intervals of every descendant. Moving a subtree does the same on a larger scale. Deleting a node rewrites the intervals of what remains below it. One logical operation becomes many row updates, and the cost grows with the size of the tree rather than with the size of the operation.
The README names this write amplification as the fundamental reason the library is hard to maintain, and then lists the three failure modes that follow from it. Concurrent writes can race and corrupt the tree. Bulk operations bypass the update logic entirely, because the library only hooks into the save and delete paths it knows about. And any code that touches the database outside django-mptt, whether raw SQL, bulk_update, or a migration, can leave the tree in an inconsistent state.
That third point is worth dwelling on, because it is the one that bites in production. A tree column is not an ordinary field. If you write to it directly, or run a queryset update that skips model save, you can break the invariant that every row depends on. There is no database constraint that would stop you, because the invariant spans rows.
The feature list in the README does describe real work being done. A TreeManager is attached to every registered model and provides methods to move nodes around a tree or into a different tree, to insert a node anywhere in a tree, and to rebuild the MPTT fields, which the README notes is useful when you do bulk updates outside of Django. Levels are sorted automatically by a field or fields of your choice, so ordering is part of the design rather than something bolted on with a sort parameter.
The README tells you to consider something else
This is the part worth reading before anything else in the repository. The top of the README is a banner, not a feature list, and it reads: This project is currently unmaintained.
The text after the banner explains the reasoning in engineering terms rather than appealing to sentiment. django-mptt itself is kept alive on a best effort basis. Bugs get fixed when reported with a clear reproduction, and compatibility with new Django versions is maintained, but new features will not be added. That is a precise and unusual thing for a maintainer to put at the top of a file, and it tells you exactly what kind of project this is now.
The recommended alternative is equally specific. Newer databases support recursive Common Table Expressions, which let you store only a parent foreign key and compute ancestry on the fly. The README points at django-tree-queries for such an implementation and at an announcement blog post explaining the approach. It also links to the trees and graphs grid on Django Packages, where other alternatives are listed.
The activity data matches the stated policy rather than contradicting it. The default branch is main, the last push to it landed on 2 June 2026, and the package still classifies itself as Development Status 5, Production or Stable. Bugs still getting fixed and new Django versions still being tested is precisely what best effort maintenance means, so a recent commit date here is evidence of compatibility work, not of an active feature roadmap.
Read together, the message is coherent. If you are starting a new project, or can afford to migrate, the README would rather you did. If you have a working installation, nothing in the banner changes your upgrade path.
Rebuilding the tree when code has stepped around it
Every library that keeps derived state needs a repair path, and django-mptt names its own clearly. Rebuilding with rebuild() is the escape hatch, but the README is blunt about the cost: it locks the table. On a large tree that is a maintenance window, not something to run inside a request.
The rebuild exists because the invariant is only maintained by the library's own code paths. Anything that writes around them, including a bulk update, raw SQL, or a migration that touches tree fields, leaves the intervals potentially wrong. Running rebuild() recomputes the left, right, level and tree id values from the parent relationships, and the result is a tree that is correct again even if the row contents were always correct.
That is a genuinely useful property. A derived-column design can fail in a way that is hard to detect, since a tree can look plausible while the intervals are subtly wrong, and queries return wrong answers rather than raising. Having a deterministic rebuild means recovery is a known operation with a known cost, which is better than a design where corruption is silent and unrecoverable.
Beyond the manager, the package ships the surrounding pieces you would otherwise write yourself. There are form fields for tree models, a set of utility functions, template tags and filters for rendering trees, and admin classes for visualising and modifying trees in the Django administration interface. The admin piece matters more than it sounds. Reordering a tree by hand through a form is miserable, and a drag and drop interface that also rewrites the intervals correctly removes an entire category of support tickets.
The docs live at django-mptt.readthedocs.io, with separate pages for forms, utilities, templates and admin. The README links each of these feature bullets to the corresponding page, so the overview doubles as a table of contents for the external documentation.
Choosing between pre-computed columns and a parent pointer
The comparison the README invites is not really between django-mptt and nothing. It is between two families of tree storage, and the search data for this project shows both of them coming up repeatedly: readers are querying django-mptt versus treebeard, and django-tree-queries appears in the same result set.
Treebeard is the closest alternative with the same shape of solution. Both libraries pre-compute positional data, both trade write simplicity for read speed, and both have been in the Django ecosystem long enough to have accumulated the same kind of subtle bug reports. If your reason for choosing django-mptt was the admin integration or the template tags, treebeard is worth a look before you migrate to something structurally different.
django-tree-queries is the other direction entirely. It stores a plain parent foreign key and computes ancestry at query time with a recursive CTE, which means there is no denormalised state to keep consistent and therefore no rebuild step, no write amplification, and no class of concurrency bug around renumbering. The price is that ancestry computation happens per query, so very deep or very wide trees pay for it every time. That is a good trade for typical Django data, where trees are shallow and reads are not as hot as the pre-computed approach assumes.
The tree file in the repository is telling about the maintenance posture. Beyond the package directory and the tests, there is a tox.ini for multi-version testing, a pre-commit config, an .editorconfig, an AGENTS.md, and a NOTES file. There is also an INSTALL file, a leftover from before packaging moved to pyproject. The package directory is what gets built, and hatchling is configured to include only that path.
The code quality tooling is configured in pyproject rather than a separate config file. Ruff runs with a wide rule selection, and the ignored list is short and mostly about line length and complexity:
[tool.ruff]
lint.extend-select = ["B", "E", "F", "W", "C90", "I", "UP", "C4", "PIE", "INT", "YTT", "G", "RUF"]A wide rule set with fix enabled by default tells you the maintainers ran the formatter over the code recently, which is consistent with a project that is being kept compiling rather than extended.
Editorial conclusion
django-mptt is still a good piece of engineering that still solves a real problem, and the README is unusually honest about why it stopped being the default choice. If you already have it in a production project, the pre-computed columns are working and the practical move is to keep them, pin your Django version, and remember that rebuild() is the escape hatch whenever code bypasses the library. If you are choosing today for a new project, the README makes its own argument: a parent foreign key plus recursive CTEs removes the write amplification and the class of concurrency bugs that this design creates. The last commit on the default branch landed on 2 June 2026, which reflects compatibility work rather than new development.
Frequently asked questions
Is django-mptt still maintained?
The README opens with a banner stating that the project is currently unmaintained. It is kept alive on a best effort basis: bugs get fixed when reported with a clear reproduction and new Django versions are supported, but no new features are being added.
Should a new Django project use django-mptt or a parent pointer with recursive CTEs?
The README itself recommends the alternative for new projects, pointing at django-tree-queries, which stores only a parent foreign key and computes ancestry with a recursive Common Table Expression. That removes the write amplification and the corruption risk that pre-computed columns introduce.
What happens if I update tree rows with raw SQL or bulk operations?
The README warns that any code touching the database outside django-mptt, including raw SQL, bulk_update and migrations, can leave the tree inconsistent because bulk operations bypass the update logic. Rebuilding with rebuild() is the escape hatch, but it locks the table.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/django-mptt-django-mptt)