Drag & Drop TreeView Nodes to Reorder Items with a Custom Tree Model in PyQt6

Implement drag and drop reordering in QTreeView using a custom QAbstractItemModel
Heads up! You've already completed this tutorial.

PyQt6's QTreeView supports drag and drop out of the box when you use built-in models like QStandardItemModel. But when you build your own custom model by subclassing QAbstractItemModel, you need to implement several methods yourself to make drag and drop work. In this tutorial, we'll build a complete working example of a tree view with a custom model that lets you drag nodes around to reorder and reparent them.

The Tree Data Structure

Before we get into the model, we need a simple data structure to represent nodes in our tree. Each node holds some data, knows its parent, and keeps a list of children.

python
class TreeNode:
    def __init__(self, data, parent=None):
        self.data = data
        self.parent = parent
        self.children = []

    def append_child(self, child):
        child.parent = self
        self.children.append(child)

    def remove_child(self, row):
        if 0 <= row < len(self.children):
            child = self.children.pop(row)
            child.parent = None
            return child
        return None

    def child(self, row):
        if 0 <= row < len(self.children):
            return self.children[row]
        return None

    def child_count(self):
        return len(self.children)

    def row(self):
        if self.parent:
            return self.parent.children.index(self)
        return 0

    def __repr__(self):
        return f"TreeNode({self.data!r})"

The row() method returns the position of this node within its parent's children list. This is used heavily by the model to create QModelIndex objects.

Building the Custom Tree Model

Now let's build our custom model. We subclass QAbstractItemModel and implement the required methods: index(), parent(), rowCount(), columnCount(), and data().

On top of these, to support drag and drop, we also need to implement:

  • flags() — to mark items as draggable and droppable
  • supportedDropActions() — to declare which drop actions we allow
  • mimeTypes() — to declare the MIME type for our drag data
  • mimeData() — to serialize dragged items
  • dropMimeData() — to deserialize and insert dropped items
  • removeRows() — to remove items from their original position after a move

That's quite a list, but we'll work through each one.

python
import json
from PyQt6.QtCore import (
    QAbstractItemModel, QModelIndex, Qt, QMimeData, QByteArray
)


class TreeModel(QAbstractItemModel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.root = TreeNode("Root")

    def index(self, row, column, parent=QModelIndex()):
        if not self.hasIndex(row, column, parent):
            return QModelIndex()

        parent_node = parent.internalPointer() if parent.isValid() else self.root
        child_node = parent_node.child(row)

        if child_node:
            return self.createIndex(row, column, child_node)
        return QModelIndex()

    def parent(self, index):
        if not index.isValid():
            return QModelIndex()

        child_node = index.internalPointer()
        parent_node = child_node.parent

        if parent_node is None or parent_node is self.root:
            return QModelIndex()

        return self.createIndex(parent_node.row(), 0, parent_node)

    def rowCount(self, parent=QModelIndex()):
        if parent.column() > 0:
            return 0
        node = parent.internalPointer() if parent.isValid() else self.root
        return node.child_count()

    def columnCount(self, parent=QModelIndex()):
        return 1

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        node = index.internalPointer()
        if role == Qt.DisplayRole:
            return node.data
        return None

This gives us a read-only tree model. The internalPointer() on each QModelIndex stores a reference to the underlying TreeNode, which makes it straightforward to navigate the tree.

Adding Drag and Drop Support

Now we add the methods that enable drag and drop. Let's start with flags() and supportedDropActions():

python
    def flags(self, index):
        default_flags = super().flags(index)
        if index.isValid():
            return (
                default_flags
                | Qt.ItemIsDragEnabled
                | Qt.ItemIsDropEnabled
            )
        # The root (invalid index) should accept drops too,
        # so items can be dropped at the top level.
        return default_flags | Qt.ItemIsDropEnabled

    def supportedDropActions(self):
        return Qt.MoveAction

Qt.ItemIsDragEnabled allows a node to be picked up. Qt.ItemIsDropEnabled allows a node to receive drops (children dropped onto it). We also enable Qt.ItemIsDropEnabled on the invalid index (which represents the root) so users can drop items at the top level of the tree.

supportedDropActions() returns Qt.MoveAction, meaning we want items to be moved rather than copied.

Serializing Nodes with MIME Data

When a user drags a node, Qt needs to package the node into a portable format. We use a custom MIME type and serialize the node path (its position in the tree) as JSON.

python
    MIME_TYPE = "application/x-treenode"

    def mimeTypes(self):
        return [self.MIME_TYPE]

    def mimeData(self, indexes):
        mime_data = QMimeData()
        paths = []
        for index in indexes:
            if index.isValid():
                path = self._get_node_path(index)
                paths.append(path)
        mime_data.setData(
            self.MIME_TYPE,
            QByteArray(json.dumps(paths).encode("utf-8")),
        )
        return mime_data

    def _get_node_path(self, index):
        """Return the path from root to this index as a list of row numbers."""
        path = []
        while index.isValid():
            path.insert(0, index.row())
            index = index.parent()
        return path

    def _node_from_path(self, path):
        """Retrieve a TreeNode from a path (list of row numbers from root)."""
        node = self.root
        for row in path:
            node = node.child(row)
            if node is None:
                return None
        return node

We encode the tree path — a list of row indices from the root down to the node — because this lets us find the node again later, even after the model has changed. We'll use _node_from_path in dropMimeData() to retrieve the actual node.

Handling the Drop

The dropMimeData() method is called when the user releases the mouse to complete a drop. This is where we insert the dragged node into its new position.

python
    def dropMimeData(self, data, action, row, column, parent):
        if action == Qt.IgnoreAction:
            return True
        if not data.hasFormat(self.MIME_TYPE):
            return False

        raw = bytes(data.data(self.MIME_TYPE)).decode("utf-8")
        paths = json.loads(raw)

        parent_node = parent.internalPointer() if parent.isValid() else self.root

        # Determine the insertion row.
        if row == -1:
            # Dropped directly on a node — append as last child.
            insert_row = parent_node.child_count()
        else:
            insert_row = row

        for path in paths:
            source_node = self._node_from_path(path)
            if source_node is None:
                continue

            # Prevent dropping a node onto itself or its own descendant.
            check = parent_node
            while check is not None:
                if check is source_node:
                    return False
                check = check.parent

            # Remove from old position.
            old_parent = source_node.parent
            old_row = source_node.row()
            old_parent_index = self._index_for_node(old_parent)

            self.beginRemoveRows(old_parent_index, old_row, old_row)
            old_parent.remove_child(old_row)
            self.endRemoveRows()

            # Adjust insert_row if the removal shifted things.
            if old_parent is parent_node and old_row < insert_row:
                insert_row -= 1

            # Insert at new position.
            self.beginInsertRows(parent, insert_row, insert_row)
            source_node.parent = parent_node
            parent_node.children.insert(insert_row, source_node)
            self.endInsertRows()

            insert_row += 1  # Next dragged node goes after this one.

        return True

    def _index_for_node(self, node):
        """Return a QModelIndex for the given node (invalid index for root)."""
        if node is self.root or node is None:
            return QModelIndex()
        return self.createIndex(node.row(), 0, node)

There's a lot happening in dropMimeData(), so let's walk through it:

  1. Determine the parent and row. If row is -1, the item was dropped directly on top of a node, so we append it as the last child. Otherwise, row tells us the exact position within the parent's children.

  2. Prevent circular drops. We walk up the parent chain from the drop target to make sure we're not trying to drop a node onto itself or one of its own descendants. Without this check, you could break the tree structure.

  3. Remove from the old position. We call beginRemoveRows() / endRemoveRows() around the actual removal. This notifies the view that rows are disappearing.

  4. Adjust the insertion index. If the source and destination share the same parent, and the source was above the insertion point, removing the source shifts everything up by one, so we need to decrement insert_row.

  5. Insert at the new position. We call beginInsertRows() / endInsertRows() around the insertion.

Because we handle the removal and insertion ourselves inside dropMimeData(), we return True to tell Qt the drop is fully handled. Qt won't try to call removeRows() after a successful move when the drop handler manages everything internally like this.

Putting It All Together

Here's the complete working example. It creates a small tree with a few nested items and a QTreeView with drag and drop enabled.

python
import sys
import json

from PyQt6.QtWidgets import QApplication, QTreeView, QVBoxLayout, QWidget
from PyQt6.QtCore import (
    QAbstractItemModel, QModelIndex, Qt, QMimeData, QByteArray
)


class TreeNode:
    def __init__(self, data, parent=None):
        self.data = data
        self.parent = parent
        self.children = []

    def append_child(self, child):
        child.parent = self
        self.children.append(child)

    def remove_child(self, row):
        if 0 <= row < len(self.children):
            child = self.children.pop(row)
            child.parent = None
            return child
        return None

    def child(self, row):
        if 0 <= row < len(self.children):
            return self.children[row]
        return None

    def child_count(self):
        return len(self.children)

    def row(self):
        if self.parent:
            return self.parent.children.index(self)
        return 0

    def __repr__(self):
        return f"TreeNode({self.data!r})"


class TreeModel(QAbstractItemModel):
    MIME_TYPE = "application/x-treenode"

    def __init__(self, parent=None):
        super().__init__(parent)
        self.root = TreeNode("Root")
        self._setup_sample_data()

    def _setup_sample_data(self):
        fruits = TreeNode("Fruits")
        fruits.append_child(TreeNode("Apple"))
        fruits.append_child(TreeNode("Banana"))
        fruits.append_child(TreeNode("Cherry"))
        self.root.append_child(fruits)

        vegetables = TreeNode("Vegetables")
        vegetables.append_child(TreeNode("Carrot"))
        vegetables.append_child(TreeNode("Broccoli"))
        self.root.append_child(vegetables)

        grains = TreeNode("Grains")
        grains.append_child(TreeNode("Rice"))
        grains.append_child(TreeNode("Wheat"))
        grains.append_child(TreeNode("Oats"))
        self.root.append_child(grains)

    # --- Core model methods ---

    def index(self, row, column, parent=QModelIndex()):
        if not self.hasIndex(row, column, parent):
            return QModelIndex()
        parent_node = parent.internalPointer() if parent.isValid() else self.root
        child_node = parent_node.child(row)
        if child_node:
            return self.createIndex(row, column, child_node)
        return QModelIndex()

    def parent(self, index):
        if not index.isValid():
            return QModelIndex()
        child_node = index.internalPointer()
        parent_node = child_node.parent
        if parent_node is None or parent_node is self.root:
            return QModelIndex()
        return self.createIndex(parent_node.row(), 0, parent_node)

    def rowCount(self, parent=QModelIndex()):
        if parent.column() > 0:
            return 0
        node = parent.internalPointer() if parent.isValid() else self.root
        return node.child_count()

    def columnCount(self, parent=QModelIndex()):
        return 1

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        node = index.internalPointer()
        if role == Qt.DisplayRole:
            return node.data
        return None

    # --- Drag and drop methods ---

    def flags(self, index):
        default_flags = super().flags(index)
        if index.isValid():
            return (
                default_flags
                | Qt.ItemIsDragEnabled
                | Qt.ItemIsDropEnabled
            )
        return default_flags | Qt.ItemIsDropEnabled

    def supportedDropActions(self):
        return Qt.MoveAction

    def mimeTypes(self):
        return [self.MIME_TYPE]

    def mimeData(self, indexes):
        mime_data = QMimeData()
        paths = []
        for index in indexes:
            if index.isValid():
                paths.append(self._get_node_path(index))
        mime_data.setData(
            self.MIME_TYPE,
            QByteArray(json.dumps(paths).encode("utf-8")),
        )
        return mime_data

    def dropMimeData(self, data, action, row, column, parent):
        if action == Qt.IgnoreAction:
            return True
        if not data.hasFormat(self.MIME_TYPE):
            return False

        raw = bytes(data.data(self.MIME_TYPE)).decode("utf-8")
        paths = json.loads(raw)

        parent_node = (
            parent.internalPointer() if parent.isValid() else self.root
        )

        if row == -1:
            insert_row = parent_node.child_count()
        else:
            insert_row = row

        for path in paths:
            source_node = self._node_from_path(path)
            if source_node is None:
                continue

            # Prevent dropping a node onto itself or a descendant.
            check = parent_node
            while check is not None:
                if check is source_node:
                    return False
                check = check.parent

            # Remove from old location.
            old_parent = source_node.parent
            old_row = source_node.row()
            old_parent_index = self._index_for_node(old_parent)

            self.beginRemoveRows(old_parent_index, old_row, old_row)
            old_parent.remove_child(old_row)
            self.endRemoveRows()

            # Adjust if removing shifted the target.
            if old_parent is parent_node and old_row < insert_row:
                insert_row -= 1

            # Insert at new location.
            self.beginInsertRows(parent, insert_row, insert_row)
            source_node.parent = parent_node
            parent_node.children.insert(insert_row, source_node)
            self.endInsertRows()

            insert_row += 1

        return True

    # --- Helpers ---

    def _get_node_path(self, index):
        path = []
        while index.isValid():
            path.insert(0, index.row())
            index = index.parent()
        return path

    def _node_from_path(self, path):
        node = self.root
        for row in path:
            node = node.child(row)
            if node is None:
                return None
        return node

    def _index_for_node(self, node):
        if node is self.root or node is None:
            return QModelIndex()
        return self.createIndex(node.row(), 0, node)


class MainWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Tree Drag & Drop Example")
        self.resize(400, 500)

        layout = QVBoxLayout(self)

        self.model = TreeModel()

        self.tree = QTreeView()
        self.tree.setModel(self.model)
        self.tree.setDragEnabled(True)
        self.tree.setAcceptDrops(True)
        self.tree.setDropIndicatorShown(True)
        self.tree.setDragDropMode(QTreeView.InternalMove)
        self.tree.expandAll()

        layout.addWidget(self.tree)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())

Run this and you'll see a tree with three categories — Fruits, Vegetables, and Grains — each with a few child items. You can drag any node and drop it somewhere else in the tree.

Try these interactions to see how it works:

  • Reorder within a group: drag "Cherry" above "Apple" within the Fruits group.
  • Move between groups: drag "Carrot" from Vegetables and drop it onto Fruits.
  • Reparent to root level: drag "Rice" and drop it between the top-level groups.
  • Move a whole group: drag the "Grains" node and drop it above "Fruits" to reorder the categories themselves.

How the Drag and Drop Flow Works

Understanding the sequence of calls Qt makes during a drag and drop helps when debugging:

  1. The user starts dragging an item. Qt calls mimeData() to serialize the selected items.
  2. As the user hovers over potential drop targets, Qt calls flags() on each item to check Qt.ItemIsDropEnabled.
  3. When the user drops, Qt calls dropMimeData() with the serialized data, the drop action, and the target position.
  4. If dropMimeData() returns True, the operation is complete. Since we handle both removal and insertion inside dropMimeData(), there's nothing else for Qt to do.
Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Bring Your PyQt/PySide Application to Market

Stuck in development hell? I'll help you get your project focused, finished and released. Benefit from years of practical experience releasing software with Python.

Find out More

Martin Fitzpatrick

Drag & Drop TreeView Nodes to Reorder Items with a Custom Tree Model in PyQt6 was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.