QSystemTrayIcon — Adding Menu Items in a Loop

Why dynamically created QActions disappear and how to fix it
Heads up! You've already completed this tutorial.

I'm trying to populate a QSystemTrayIcon context menu using a for loop, but only the last item shows up. What happened to the other entries, and how do I fix it?

If you've ever tried to build a system tray menu dynamically — say, by looping through a list of items — you may have run into a confusing problem: only the last menu item appears. The earlier ones vanish without any error message. Let's look at why this happens and how to solve it.

The problem: disappearing menu items

Here's a minimal example that demonstrates the issue. We have a list of three entries and we loop through them, creating a QAction for each one:

python
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu
from PyQt6.QtGui import QAction, QIcon


app = QApplication([])
app.setQuitOnLastWindowClosed(False)

icon = QIcon.fromTheme("application")
tray = QSystemTrayIcon()
tray.setIcon(icon)
tray.setVisible(True)

menu = QMenu()
entries = ["One", "Two", "Three"]
for entry in entries:
    action = QAction(entry)
    menu.addAction(action)
    action.triggered.connect(app.quit)

tray.setContextMenu(menu)

app.exec()

You'd expect to see three items in the menu — "One", "Two", and "Three" — but only "Three" appears. The first two have vanished.

Why this happens: Python garbage collection

The cause is Python's garbage collector. When you create an object and assign it to a variable, Python keeps that object alive as long as something is referencing it. Once nothing references it anymore, Python is free to delete it.

In the loop above, the variable action is reassigned on every iteration. On the second pass through the loop, action now points to the new QAction("Two"), and the previous QAction("One") no longer has any Python reference. Python garbage-collects it, which also destroys the underlying Qt C++ object — and removes it from the menu.

By the time the loop finishes, only the last QAction survives because action still points to it.

This is a common source of confusion when working with PyQt6 (and PySide6). Qt's C++ side doesn't always take full ownership of objects you pass to it, so Python's garbage collector can sweep them away unexpectedly.

The fix: keep references to your actions

The solution is straightforward — store each QAction somewhere so Python keeps it alive. A simple list works perfectly:

Bring Your PyQt/PySide Application to Market — Specialized launch support for scientific and engineering software built using Python & Qt.

Find out More

python
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu
from PyQt6.QtGui import QAction, QIcon


app = QApplication([])
app.setQuitOnLastWindowClosed(False)

icon = QIcon.fromTheme("application")
tray = QSystemTrayIcon()
tray.setIcon(icon)
tray.setVisible(True)

menu = QMenu()
entries = ["One", "Two", "Three"]
actions = []
for entry in entries:
    action = QAction(entry)
    menu.addAction(action)
    action.triggered.connect(app.quit)
    actions.append(action)

tray.setContextMenu(menu)

app.exec()

The only change is the actions list. By appending each QAction to this list, we ensure that every action has a live Python reference for the lifetime of the program. Now all three items appear in the menu as expected.

An alternative: set the menu as the action's parent

Another approach is to pass a parent to each QAction. When a Qt object has a parent, Qt takes ownership of it and manages its lifetime. This means Python's garbage collector won't destroy it, because the C++ side is keeping it alive:

python
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu
from PyQt6.QtGui import QAction, QIcon


app = QApplication([])
app.setQuitOnLastWindowClosed(False)

icon = QIcon.fromTheme("application")
tray = QSystemTrayIcon()
tray.setIcon(icon)
tray.setVisible(True)

menu = QMenu()
entries = ["One", "Two", "Three"]
for entry in entries:
    action = QAction(entry, menu)  # menu is the parent
    menu.addAction(action)
    action.triggered.connect(app.quit)

tray.setContextMenu(menu)

app.exec()

By passing menu as the second argument to QAction, each action becomes a child of the menu. Qt will keep the action alive as long as the menu exists, so there's no need for a separate list.

Both approaches work well. Using a parent is often the cleaner option when you don't need to reference the actions later. Keeping a list is useful when you want to modify or remove specific actions after creation.

Complete working example

Here's a slightly more fleshed-out version that gives each menu item its own behavior, so you can see how to connect different actions to different slots:

python
import sys

from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu
from PyQt6.QtGui import QAction, QIcon


def on_action_triggered(text):
    print(f"Selected: {text}")


app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)

icon = QIcon.fromTheme("application")
tray = QSystemTrayIcon()
tray.setIcon(icon)
tray.setVisible(True)

menu = QMenu()

entries = ["One", "Two", "Three"]
actions = []
for entry in entries:
    action = QAction(entry, menu)
    menu.addAction(action)
    # Use a default argument to capture the current value of entry
    action.triggered.connect(lambda checked, text=entry: on_action_triggered(text))
    actions.append(action)

# Add a separator and a quit option
menu.addSeparator()
quit_action = QAction("Quit", menu)
quit_action.triggered.connect(app.quit)
menu.addAction(quit_action)

tray.setContextMenu(menu)

sys.exit(app.exec())

Notice the lambda with a default argument (text=entry). This is a standard Python pattern for capturing the current loop variable's value inside a closure. Without text=entry, every lambda would reference the same entry variable and they'd all print "Three".

Conclusion

When you create Qt objects in a loop and they seem to disappear, the cause is almost always garbage collection. Python cleans up objects that no longer have references, and if you keep reassigning the same variable in a loop, only the last object survives.

The fix is to either store your objects in a list or give them a Qt parent so the C++ side manages their lifetime. This applies not just to QAction and menus, but to any Qt object you create dynamically — widgets, layouts, timers, and more. Once you're aware of this pattern, you'll recognize it immediately whenever things start vanishing.

For a complete guide to building system tray applications with menus, icons, and more, see the System Tray & Mac Menu Bar Applications tutorial. If you'd like to learn more about how signals, slots, and the lambda pattern used above work in practice, take a look at Signals, Slots & Events. To understand how actions, toolbars, and menus fit together in larger applications, the Actions, Toolbars & Menus tutorial covers these topics in depth. If you're new to PyQt6 and want to get your environment set up, see the PyQt6 installation guide for Windows.

The complete guide to packaging Python GUI applications with PyInstaller.
[[ discount.discount_pc ]]% OFF for the next [[ discount.duration ]] [[discount.description ]] with the code [[ discount.coupon_code ]]

Purchasing Power Parity

Developers in [[ country ]] get [[ discount.discount_pc ]]% OFF on all books & courses with code [[ discount.coupon_code ]]
Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak
Martin Fitzpatrick

QSystemTrayIcon — Adding Menu Items in a Loop 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.