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.expa