QTimer, threads, and non-gui events

How to coordinate timed events with sequential logic in PySide6
Heads up! You've already completed this tutorial.

I have a QTimer and a loop running in my GUI application. I want the timer ticks to interleave with the steps of my loop — so each iteration waits for a timer tick before continuing. But when I run the loop, it completes all at once and the timer only fires afterward. How can I coordinate timed events with sequential logic without blocking the GUI?

This is a really common stumbling block when working with Qt, and it comes down to how the event loop works. Once you understand that, the solution becomes clear — and you won't need threads for this at all.

Why the loop finishes before the timer fires

Qt applications are driven by an event loop. This is the engine that processes user input, repaints the window, and dispatches signals (including timer signals). The event loop can only do its job when your code isn't running — that is, when control has returned to the event loop.

When you click the button and _do_thing is called, your for loop runs from start to finish before control returns to the event loop. The timer may well have "ticked" during that time, but the signal can't be delivered until the loop is done and the event loop gets a chance to process it.

That's why you see all the n= and m= output first, followed by all the timer ticks afterward.

The event-driven approach

The temptation is to find a way to "pause" inside the loop and wait for the timer. But in GUI programming, pausing (or sleeping) inside a slot blocks the entire interface. Instead, the standard approach is to restructure your logic so that the timer drives the sequence of steps.

Rather than writing a loop that waits for ticks, you let each tick advance your state by one step. You keep track of where you are using instance variables, and each time the timer fires, you do the next piece of work.

Here's what that looks like in practice:

python
from PySide6 import QtCore, QtWidgets


class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self._setup_timer()

        layout = QtWidgets.QVBoxLayout()
        self.label = QtWidgets.QLabel("Tick tester")
        self.button = QtWidgets.QPushButton("START")
        self.button.pressed.connect(self._start_sequence)
        layout.addWidget(self.label)
        layout.addWidget(self.button)

        w = QtWidgets.QWidget()
        w.setLayout(layout)
        self.setCentralWidget(w)
        self.show()

    def _setup_timer(self):
        self._timer = QtCore.QTimer()
        self._timer.timeout.connect(self._on_tick)

    def _start_sequence(self):
        """Initialize state and start the timer-driven sequence."""
        self._n = 0
        self._m = 0
        self._tick_count = 0
        self._n_max = 3
        self._m_max = 5
        print(f"n={self._n}")
        print(f"m={self._m}")
        self._timer.start(1000)

    def _on_tick(self):
        """Called every second. Advances one step through the sequence."""
        print(f"Timer tick: {self._tick_count}")
        print("I'm doing something every second!")
        self._tick_count += 1

        # Advance to the next m step
        self._m += 1

        if self._m < self._m_max:
            print(f"m={self._m}")
        else:
            # Finished inner loop, advance n
            self._n += 1
            if self._n < self._n_max:
                self._m = 0
                print(f"n={self._n}")
                print(f"m={self._m}")
            else:
                # All done
                self._timer.stop()
                print("do one more thing")


app = QtWidgets.QApplication([])
window = MainWindow()
app.exec()

When you run this and click START, you'll see output like:

python
n=0
m=0
Timer tick: 0
I'm doing something every second!
m=1
Timer tick: 1
I'm doing something every second!
m=2
Timer tick: 2
I'm doing something every second!
...
do one more thing

Each tick advances the state by one step, and "do one more thing" runs after the full sequence completes. The GUI remains responsive throughout because we never block the event loop.

Understanding the pattern

What we've done here is convert a sequential loop into an event-driven state machine. Instead of the loop controlling the flow, the timer controls it. The instance variables self._n and self._m hold the current position in what would have been the nested loop, and each timer tick moves forward by one step.

This is a very common pattern in GUI programming. Whenever you find yourself wanting to "wait" inside a method, consider whether you can restructure the logic so that the event loop calls you back at the right time. This is closely related to how signals and slots work in Qt — signals are the mechanism the event loop uses to notify your code that something has happened.

Here's a comparison of the two approaches:

Sequential (blocking) Event-driven (non-blocking)
for loop runs to completion Timer fires repeatedly
Blocks the GUI GUI stays responsive
Timer signals queue up Timer signals drive the work
Logic is easy to read as a loop Logic is spread across ticks

The tradeoff is that event-driven code can be a bit harder to follow, since the "loop" logic is now implicit in the state variables. But the benefit — a responsive GUI — is well worth it.

When you do need threads

For this particular case, threads aren't necessary. The work being done on each tick is lightweight (just printing), so it's fine to do it directly in the timer callback.

You would reach for threads (using QThreadPool and QRunnable, or QThread) when the work itself is heavy — for example, downloading a file, running a calculation, or processing a large dataset. In those cases, you'd typically:

  1. Start a worker in a thread.
  2. Have the worker emit signals to report progress.
  3. Connect those signals to slots in your main window to update the GUI.

But if you just need to do something on a schedule and the work is quick, QTimer with an event-driven design (as shown above) is the simplest and most robust approach.

A cleaner version with a step list

If your sequence of operations is more complex than nested counters, another approach is to build a list of steps up front and then use the timer to work through them one at a time. This keeps the logic easy to follow:

python
from PySide6 import QtCore, QtWidgets


class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        self._timer = QtCore.QTimer()
        self._timer.timeout.connect(self._process_next_step)

        self._steps = []
        self._current_step = 0

        layout = QtWidgets.QVBoxLayout()
        self.label = QtWidgets.QLabel("Tick tester")
        self.button = QtWidgets.QPushButton("START")
        self.button.pressed.connect(self._start_sequence)
        layout.addWidget(self.label)
        layout.addWidget(self.button)

        w = QtWidgets.QWidget()
        w.setLayout(layout)
        self.setCentralWidget(w)
        self.show()

    def _start_sequence(self):
        # Build the full list of steps
        self._steps = []
        tick_count = 0
        for n in range(3):
            for m in range(5):
                self._steps.append(
                    f"n={n}, m={m} | Timer tick: {tick_count}"
                )
                tick_count += 1
        self._steps.append("do one more thing")

        self._current_step = 0
        self._timer.start(1000)

    def _process_next_step(self):
        if self._current_step < len(self._steps):
            step = self._steps[self._current_step]
            print(step)
            self.label.setText(step)
            self._current_step += 1
        else:
            self._timer.stop()
            print("Sequence complete.")
            self.label.setText("Sequence complete.")


app = QtWidgets.QApplication([])
window = MainWindow()
app.exec()

This version pre-computes the steps, then the timer just walks through the list. It's easy to add, remove, or reorder steps, and the GUI updates with each one so you can see progress in the window. If you're new to building PySide6 interfaces, our tutorial on creating your first window covers the fundamentals of setting up a QMainWindow and running the application event loop.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Packaging Python Applications with PyInstaller by Martin Fitzpatrick

This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.

More info Get the book

Martin Fitzpatrick

QTimer, threads, and non-gui events 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.