PyQt6 QTreeView Tutorial: Examples with Models, Files, Checkboxes and Search

QTreeView shows hierarchical data (folders, categories, parent/child records) as an expandable tree. It displays a model: the quickest is QStandardItemModel, where you add QStandardItem rows under other items. This guide builds working PyQt6 tree views step by step: a basic tree, a tree from a nested dict, a file browser, checkboxes, search, a context menu, styling and a custom model. Every example is a complete program with a screenshot of the real window.

Tested with Python 3.12.5, PyQt6 6.11.0 (Qt 6.11.0) on Windows 11. Install PyQt6 with pip install PyQt6 (see install PyQt6). For the full reference, see the Qt 6 QTreeView documentation.

Basic QTreeView example

import sys
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication, QTreeView

app = QApplication(sys.argv)

model = QStandardItemModel()
model.setHorizontalHeaderLabels(["Name", "Type"])

fruits = QStandardItem("Fruits")
fruits.appendRow([QStandardItem("Apple"), QStandardItem("Pome")])
fruits.appendRow([QStandardItem("Mango"), QStandardItem("Drupe")])

vegetables = QStandardItem("Vegetables")
vegetables.appendRow([QStandardItem("Carrot"), QStandardItem("Root")])

model.appendRow([fruits, QStandardItem("Category")])
model.appendRow([vegetables, QStandardItem("Category")])

tree = QTreeView()
tree.setModel(model)
tree.expandAll()
tree.setColumnWidth(0, 200)
tree.setWindowTitle("QTreeView example")
tree.resize(420, 260)
tree.show()

sys.exit(app.exec())
PyQt6 QTreeView example with QStandardItemModel showing Fruits and Vegetables categories with child items and a Type column
Two parent items with children and a second column.
  • Each row is a list of items, one per column: appendRow([name_item, type_item]).
  • Appending to an item (not the model) creates a child row.
  • expandAll() opens every level; expand(index) opens one.

QTreeView vs QTreeWidget

QTreeWidget is a convenience class with its own built-in model: you create QTreeWidgetItems directly. QTreeView works with any model, so the same data can be shown, sorted and filtered in several views:

import sys
from PyQt6.QtWidgets import QApplication, QTreeWidget, QTreeWidgetItem

app = QApplication(sys.argv)

tree = QTreeWidget()
tree.setHeaderLabels(["Name", "Type"])

fruits = QTreeWidgetItem(tree, ["Fruits", "Category"])     # items know their parent
QTreeWidgetItem(fruits, ["Apple", "Pome"])
QTreeWidgetItem(fruits, ["Mango", "Drupe"])
vegetables = QTreeWidgetItem(tree, ["Vegetables", "Category"])
QTreeWidgetItem(vegetables, ["Carrot", "Root"])

tree.expandAll()
tree.setColumnWidth(0, 200)
tree.setWindowTitle("QTreeWidget example")
tree.resize(420, 260)
tree.show()

sys.exit(app.exec())
PyQt6 QTreeWidget example with QTreeWidgetItem showing the same fruits and vegetables tree
The same tree built with QTreeWidget.
QTreeWidgetQTreeView + model
SetupLeast codeA model plus a view
Filtering, sorting proxiesLimitedYes (QSortFilterProxyModel)
File system, databases, big dataNoYes (QFileSystemModel, custom models)
Same data in several viewsNoYes
Best forSmall, fixed treesEverything else

Build a tree from a nested dict

Real data is usually nested dictionaries and lists (JSON, API responses). A small recursive function turns any depth into tree items:

import sys
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication, QTreeView

data = {
    "USA": {"California": ["Los Angeles", "San Francisco"], "Texas": ["Austin", "Houston"]},
    "India": {"Maharashtra": ["Mumbai", "Pune"], "Karnataka": ["Bengaluru"]},
    "UK": ["London", "Manchester"],
}

def add_items(parent, value):
    """Add a dict, list or single value under parent (recursively)."""
    if isinstance(value, dict):
        for key, child in value.items():
            item = QStandardItem(str(key))
            parent.appendRow(item)
            add_items(item, child)
    elif isinstance(value, list):
        for child in value:
            add_items(parent, child)
    else:
        parent.appendRow(QStandardItem(str(value)))

app = QApplication(sys.argv)
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["Places"])
add_items(model.invisibleRootItem(), data)

tree = QTreeView()
tree.setModel(model)
tree.expandAll()
tree.setWindowTitle("Tree from a nested dict")
tree.resize(320, 380)
tree.show()

sys.exit(app.exec())
PyQt6 QTreeView built from a nested Python dict: countries, states and cities
Three levels from one recursive function.

File browser with QFileSystemModel

QFileSystemModel reads folders from disk (loading them in the background as they’re expanded) and shows name, size, type and date columns. setRootIndex() makes the tree start at your folder:

import sys
from pathlib import Path
from PyQt6.QtGui import QFileSystemModel
from PyQt6.QtWidgets import QApplication, QTreeView

app = QApplication(sys.argv)
folder = str(Path("my_project").resolve())        # the folder to show

model = QFileSystemModel()
model.setRootPath(folder)

tree = QTreeView()
tree.setModel(model)
tree.setRootIndex(model.index(folder))            # start at that folder, not at the drive
tree.setColumnWidth(0, 220)
tree.setSortingEnabled(True)
tree.setWindowTitle("File browser with QFileSystemModel")
tree.resize(620, 330)
tree.show()

sys.exit(app.exec())
PyQt6 QTreeView file browser with QFileSystemModel showing a project folder with app and docs folders expanded and Name, Size, Type and Date Modified columns
A project folder with two subfolders expanded.

Use model.setNameFilters(["*.py"]) and model.setNameFilterDisables(False) to show only matching files, and tree.hideColumn(1) to hide a column.

Get the selected item and its path

clicked fires on a mouse click; selectionModel().currentChanged fires whenever the current item changes, including from the keyboard. Walk up with index.parent() to build the full path:

import sys
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication, QTreeView

app = QApplication(sys.argv)
model = QStandardItemModel()
usa = QStandardItem("USA")
texas = QStandardItem("Texas")
usa.appendRow(texas)
texas.appendRow(QStandardItem("Austin"))
model.appendRow(usa)

tree = QTreeView()
tree.setModel(model)

def item_path(index):
    """Walk up the parents: USA > Texas > Austin."""
    parts = []
    while index.isValid():
        parts.insert(0, index.data())
        index = index.parent()
    return " > ".join(parts)

def on_current_changed(current, previous):
    print("selected:", current.data(), "| path:", item_path(current), "| depth:", len(item_path(current).split(" > ")) - 1)

tree.selectionModel().currentChanged.connect(on_current_changed)
tree.clicked.connect(lambda index: print("clicked:", index.data()))

# select Austin from code (a mouse click does the same)
austin = model.index(0, 0, model.index(0, 0, model.index(0, 0)))
tree.setCurrentIndex(austin)
tree.clicked.emit(austin)

Output (Austin selected):

selected: Austin | path: USA > Texas > Austin | depth: 2
clicked: Austin

Checkboxes with an automatic parent state

Make items checkable with setCheckable(True). You might expect setAutoTristate(True) to keep parents in sync, but in QStandardItemModel it doesn’t: in this test both children are checked and the parent stays unchecked:

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication

app = QApplication(sys.argv)
model = QStandardItemModel()
parent = QStandardItem("Cheese")
parent.setCheckable(True)
parent.setAutoTristate(True)
for name in ["Mozzarella", "Cheddar"]:
    child = QStandardItem(name)
    child.setCheckable(True)
    parent.appendRow(child)
model.appendRow(parent)

parent.child(0).setCheckState(Qt.CheckState.Checked)
parent.child(1).setCheckState(Qt.CheckState.Checked)
print("both children checked, parent is:", parent.checkState().name)

Output:

both children checked, parent is: Unchecked

The reliable way is a small itemChanged handler: clicking a parent sets all its children, and changing a child recomputes the parent (checked, unchecked or partly checked). The updating flag stops the handler from reacting to its own changes:

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication, QTreeView

app = QApplication(sys.argv)
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["Toppings"])

for group, toppings in {"Vegetables": ["Onion", "Pepper", "Olive"], "Cheese": ["Mozzarella", "Cheddar"]}.items():
    parent = QStandardItem(group)
    parent.setCheckable(True)
    for name in toppings:
        child = QStandardItem(name)
        child.setCheckable(True)
        parent.appendRow(child)
    model.appendRow(parent)

updating = False

def on_item_changed(item):
    # keep parents and children in sync when a checkbox changes
    global updating
    if updating or not item.isCheckable():
        return
    updating = True
    state = item.checkState()
    if item.hasChildren() and state != Qt.CheckState.PartiallyChecked:
        for r in range(item.rowCount()):               # a parent was clicked: apply to its children
            item.child(r).setCheckState(state)
    parent = item.parent()
    if parent is not None:                             # a child changed: recompute its parent
        states = {parent.child(r).checkState() for r in range(parent.rowCount())}
        parent.setCheckState(states.pop() if len(states) == 1 else Qt.CheckState.PartiallyChecked)
    updating = False

model.itemChanged.connect(on_item_changed)

def checked_items():
    result = []
    for r in range(model.rowCount()):
        parent = model.item(r)
        for c in range(parent.rowCount()):
            if parent.child(c).checkState() == Qt.CheckState.Checked:
                result.append(parent.child(c).text())
    return result

tree = QTreeView()
tree.setModel(model)
tree.expandAll()
tree.setWindowTitle("Checkable tree")
tree.resize(300, 300)
tree.show()

sys.exit(app.exec())
PyQt6 QTreeView checkboxes with tristate parents: Vegetables partly checked with Onion ticked, Cheese fully checked
Onion ticked: Vegetables becomes partly checked. Both cheeses ticked: Cheese becomes checked.

Reading the result after ticking Onion, Mozzarella and Cheddar:

checked: ['Onion', 'Mozzarella', 'Cheddar']
Vegetables: PartiallyChecked | Cheese: Checked

If you use QTreeWidget, this is built in: set the ItemIsAutoTristate flag on the parent:

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QTreeWidget, QTreeWidgetItem

app = QApplication(sys.argv)
tree = QTreeWidget()
tree.setHeaderLabels(["Toppings"])

cheese = QTreeWidgetItem(tree, ["Cheese"])
cheese.setFlags(cheese.flags() | Qt.ItemFlag.ItemIsAutoTristate)   # built into QTreeWidget
kids = []
for name in ["Mozzarella", "Cheddar"]:
    kid = QTreeWidgetItem(cheese, [name])
    kid.setFlags(kid.flags() | Qt.ItemFlag.ItemIsUserCheckable)
    kid.setCheckState(0, Qt.CheckState.Unchecked)
    kids.append(kid)

kids[0].setCheckState(0, Qt.CheckState.Checked)
print("one child checked  ->", cheese.checkState(0).name)
kids[1].setCheckState(0, Qt.CheckState.Checked)
print("both checked       ->", cheese.checkState(0).name)
cheese.setCheckState(0, Qt.CheckState.Unchecked)
print("parent unchecked   ->", [k.checkState(0).name for k in kids])

Output:

one child checked  -> PartiallyChecked
both checked       -> Checked
parent unchecked   -> ['Unchecked', 'Unchecked']

Search and filter the tree

Put a QSortFilterProxyModel between the model and the view. setRecursiveFilteringEnabled(True) keeps the parent rows of matching children visible:

import sys
from PyQt6.QtCore import QSortFilterProxyModel, Qt
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication, QLineEdit, QTreeView, QVBoxLayout, QWidget

data = {"Fruits": ["Apple", "Apricot", "Banana", "Mango"],
        "Vegetables": ["Asparagus", "Carrot", "Potato"],
        "Nuts": ["Almond", "Cashew", "Walnut"]}

app = QApplication(sys.argv)
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["Food"])
for group, items in data.items():
    parent = QStandardItem(group)
    for name in items:
        parent.appendRow(QStandardItem(name))
    model.appendRow(parent)

proxy = QSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setRecursiveFilteringEnabled(True)            # keep the parents of matching children
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)

search = QLineEdit(placeholderText="Search...")
tree = QTreeView()
tree.setModel(proxy)

def on_search(text):
    proxy.setFilterFixedString(text)
    tree.expandAll()

search.textChanged.connect(on_search)

window = QWidget()
layout = QVBoxLayout(window)
layout.addWidget(search)
layout.addWidget(tree)
window.setWindowTitle("Search a QTreeView")
window.resize(320, 330)
window.show()
tree.expandAll()

sys.exit(app.exec())
PyQt6 QTreeView search filter with QSortFilterProxyModel: typing ca shows Carrot under Vegetables and Cashew under Nuts
Typing “ca” keeps Carrot and Cashew, and their categories.

Add, rename and delete items (context menu)

A right-click menu gives users control. indexAt() finds the item under the mouse; QStandardItems are editable by default, so tree.edit(index) starts renaming:

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication, QMenu, QTreeView

app = QApplication(sys.argv)
model = QStandardItemModel()
model.setHorizontalHeaderLabels(["Tasks"])
project = QStandardItem("Website")
for task in ["Design", "Build"]:
    project.appendRow(QStandardItem(task))
model.appendRow(project)

tree = QTreeView()
tree.setModel(model)
tree.expandAll()

def add_child(index, text="New task"):
    parent = model.itemFromIndex(index) if index.isValid() else model.invisibleRootItem()
    parent.appendRow(QStandardItem(text))
    tree.expand(index)

def delete_item(index):
    model.removeRow(index.row(), index.parent())

def show_menu(pos):
    index = tree.indexAt(pos)
    menu = QMenu(tree)
    menu.addAction("Add child", lambda: add_child(index))
    if index.isValid():
        menu.addAction("Rename", lambda: tree.edit(index))    # items are editable by default
        menu.addAction("Delete", lambda: delete_item(index))
    menu.exec(tree.viewport().mapToGlobal(pos))

tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
tree.customContextMenuRequested.connect(show_menu)

tree.setWindowTitle("Add, rename and delete items")
tree.resize(320, 260)
tree.show()
sys.exit(app.exec())

Output (tree after running Add child, Rename and Delete from code):

Website
  Design v2
  Test

Icons, sorting and styling

Standard icons come from the current style, setSortingEnabled(True) lets users sort by clicking a header, and a stylesheet changes colours and spacing:

import sys
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import QApplication, QHeaderView, QStyle, QTreeView

app = QApplication(sys.argv)
folder_icon = app.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)
file_icon = app.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon)

model = QStandardItemModel()
model.setHorizontalHeaderLabels(["Name", "Size", "Modified"])
for folder, files in {"Reports": [("q1.pdf", "220 KB", "2026-03-31"), ("q2.pdf", "235 KB", "2026-06-30")],
                      "Images": [("logo.png", "18 KB", "2026-01-12"), ("banner.jpg", "410 KB", "2026-02-02")]}.items():
    parent = QStandardItem(folder_icon, folder)
    for name, size, date in files:
        size_item = QStandardItem(size)
        size_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
        parent.appendRow([QStandardItem(file_icon, name), size_item, QStandardItem(date)])
    model.appendRow([parent, QStandardItem(), QStandardItem()])

tree = QTreeView()
tree.setModel(model)
tree.setAlternatingRowColors(True)
tree.setSortingEnabled(True)                     # click a header to sort
tree.sortByColumn(0, Qt.SortOrder.AscendingOrder)
tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
tree.setStyleSheet("""
    QTreeView { font-size: 13px; }
    QTreeView::item { padding: 4px; }
    QTreeView::item:selected { background: #306998; color: white; }
    QHeaderView::section { background: #0a1b2e; color: white; padding: 5px; border: none; }
""")
tree.expandAll()
tree.setWindowTitle("Icons, sorting and styling")
tree.resize(520, 300)
tree.show()
sys.exit(app.exec())
Styled PyQt6 QTreeView with folder and file icons, Name Size and Modified columns, a dark header and alternating row colours
Icons, a right-aligned Size column and a dark header.

A custom model (QAbstractItemModel)

When your data already lives in Python objects, or is too large to copy into QStandardItems, write a model. A read-only tree model needs five methods: index, parent, rowCount, columnCount and data:

import sys
from PyQt6.QtCore import QAbstractItemModel, QModelIndex, Qt
from PyQt6.QtWidgets import QApplication, QTreeView

class Node:
    def __init__(self, name, size="", parent=None):
        self.name, self.size, self.parent, self.children = name, size, parent, []
        if parent:
            parent.children.append(self)

class TreeModel(QAbstractItemModel):
    """A read-only model over your own Python objects."""
    def __init__(self, root):
        super().__init__()
        self.root = root

    def index(self, row, column, parent=QModelIndex()):
        if not self.hasIndex(row, column, parent):
            return QModelIndex()
        node = parent.internalPointer() if parent.isValid() else self.root
        return self.createIndex(row, column, node.children[row])

    def parent(self, index):
        node = index.internalPointer() if index.isValid() else None
        if node is None or node.parent is self.root or node.parent is None:
            return QModelIndex()
        grand = node.parent.parent
        return self.createIndex(grand.children.index(node.parent), 0, node.parent)

    def rowCount(self, parent=QModelIndex()):
        if parent.column() > 0:                  # only column 0 has children
            return 0
        node = parent.internalPointer() if parent.isValid() else self.root
        return len(node.children)

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

    def data(self, index, role=Qt.ItemDataRole.DisplayRole):
        if index.isValid() and role == Qt.ItemDataRole.DisplayRole:
            node = index.internalPointer()
            return node.name if index.column() == 0 else node.size
        return None

    def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
        if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
            return ["Name", "Size"][section]
        return None

root = Node("root")
src = Node("src", parent=root)
Node("main.py", "4 KB", src)
Node("utils.py", "2 KB", src)
Node("README.md", "1 KB", root)

app = QApplication(sys.argv)
model = TreeModel(root)
tree = QTreeView()
tree.setModel(model)
tree.expandAll()
tree.setColumnWidth(0, 200)
tree.setWindowTitle("Custom QAbstractItemModel")
tree.resize(360, 220)
tree.show()
sys.exit(app.exec())
PyQt6 QTreeView with a custom QAbstractItemModel showing a src folder with main.py and utils.py and a README file
The same view, now backed by plain Python objects.

Two rules are easy to miss: only column 0 may have children (rowCount() returns 0 for other columns), and index() should check hasIndex() first. Qt ships a checker for models, QAbstractItemModelTester; my first draft of this model broke those rules and the checker caught it. The version above passes:

rows at top: 2 | children of src: 2
parent of main.py: src

Common problems

  • Nothing shows: keep a reference to the model (e.g. self.model); a model created in a function and not stored can be garbage-collected.
  • Only the top level appears: children are collapsed; call expandAll() or click the arrows.
  • QFileSystemModel shows the whole drive: call tree.setRootIndex(model.index(folder)).
  • Filter hides children’s parents: enable setRecursiveFilteringEnabled(True) on the proxy.
  • Selected index from a proxy: convert with proxy.mapToSource(index) before using it with the source model.

More PyQt6 tutorials:

Frequently asked questions

How do I create a QTreeView in PyQt6?

Create a QStandardItemModel, add QStandardItem rows (append items to other items for children), then call tree.setModel(model) on a QTreeView.

What is the difference between QTreeView and QTreeWidget?

QTreeWidget has a built-in model and is quickest for small fixed trees. QTreeView works with any model, which enables file system models, custom models, sorting and filtering proxies.

How do I get the selected item in a QTreeView?

Use tree.currentIndex() or connect to tree.selectionModel().currentChanged. index.data() gives the text; model.itemFromIndex(index) gives the QStandardItem.

How do I add checkboxes to a QTreeView?

Call item.setCheckable(True) on each item. To keep parents in sync with their children in a QStandardItemModel, connect model.itemChanged to a handler like the one above; in QTreeWidget, set the ItemIsAutoTristate flag on parents.

How do I filter a QTreeView?

Use a QSortFilterProxyModel between model and view, call setRecursiveFilteringEnabled(True), and set the text with setFilterFixedString().