Sequential programming, threads, causality, and the nature of time

Why updating the GUI before a long-running task doesn't work the way you'd expect
Heads up! You've already completed this tutorial.

I update some GUI widgets and then immediately call a long I/O function. Somehow the I/O interferes with the GUI update that came before it — like a time machine! I'm guessing the GUI commands just get placed in a queue that the long I/O blocks. Is there a way to "flush the queue" or force the GUI to update before the I/O starts? I can deal with this using threads, but it seems inelegant. Any suggestions?

Every PyQt6 application has an event loop. When you call app.exec(), you hand control over to Qt, and it starts processing events one at a time from a queue. These events include things like mouse clicks, keyboard input, window resizes, and — importantly — paint events (the events that actually draw widgets on screen).

When one of your slots or methods is running, the event loop is waiting for it to finish. It can't process any other events until your code returns. This includes the paint events that would update your labels, progress bars, or any other widget.

So when you write something like this:

python
self.label.setText("Processing...")
self.do_long_io_operation()

Here's what actually happens:

  1. setText("Processing...") schedules a paint event. It marks the label as needing a redraw, but the redraw doesn't happen yet.
  2. do_long_io_operation() runs immediately — and blocks the event loop for the entire duration.
  3. Only after do_long_io_operation() returns does the event loop regain control and process the queued paint event.

The result? The label appears to update after the I/O finishes, or not at all if you change it again. It looks like step 2 is interfering with step 1 — like causality is broken. But really, step 1 never completed in the way you expected. The GUI update was deferred, and the long task prevented it from being processed.

Can you force the GUI to update?

You might think you can force the GUI to process pending events before starting the long operation. And technically, you can — using QApplication.processEvents():

python
self.label.setText("Processing...")
QApplication.processEvents()  # Force pending events to be processed
self.do_long_io_operation()

This tells Qt to process everything currently in the event queue, including paint events, before continuing. Your label will update before the I/O starts.

However, this approach has real limitations. During the long I/O operation, the event loop is still blocked. Your GUI will freeze — buttons won't respond, windows won't redraw if you drag another window over them, and your application may appear to have crashed. Calling processEvents() is a band-aid, not a solution.

Sprinkling processEvents() calls throughout your code can also lead to subtle bugs. If a user clicks a button while processEvents() is running inside your I/O function, that click handler will execute in the middle of your I/O logic. This kind of reentrancy is difficult to debug and can cause unpredictable behavior.

Threads are the right approach

Moving the long-running work off the main thread is the proper way to handle this. It might feel like overkill for a simple "update a label then do some work" scenario, but it solves the problem completely: your GUI stays responsive the entire time, updates happen when you expect them to, and users can continue interacting with your application.

PyQt6 provides QThreadPool and QRunnable for exactly this purpose. Here's a complete, working example that demonstrates the pattern:

python
import sys
import time

from PyQt6.QtCore import (
    QRunnable,
    QThreadPool,
    pyqtSignal,
    pyqtSlot,
    QObject,
)
from PyQt6.QtWidgets import (
    QApplication,
    QLabel,
    QMainWindow,
    QPushButton,
    QVBoxLayout,
    QWidget,
)


class WorkerSignals(QObject):
    """Signals for communicating from the worker thread back to the GUI."""

    finished = pyqtSignal()
    progress = pyqtSignal(int)


class Worker(QRunnable):
    """A worker that runs a long task in a separate thread."""

    def __init__(self):
        super().__init__()
        self.signals = WorkerSignals()

    @pyqtSlot()
    def run(self):
        """Simulate a long I/O operation."""
        for i in range(11):
            time.sleep(0.5)  # Simulate work
            self.signals.progress.emit(i * 10)

        self.signals.finished.emit()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Threading Example")

        self.label = QLabel("Ready.")
        self.button = QPushButton("Start I/O Task")
        self.button.clicked.connect(self.start_task)

        layout = QVBoxLayout()
        layout.addWidget(self.label)
        layout.addWidget(self.button)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        self.threadpool = QThreadPool()

    def start_task(self):
        self.label.setText("Processing...")
        self.button.setEnabled(False)

        worker = Worker()
        worker.signals.progress.connect(self.update_progress)
        worker.signals.finished.connect(self.task_finished)

        self.threadpool.start(worker)

    def update_progress(self, value):
        self.label.setText(f"Progress: {value}%")

    def task_finished(self):
        self.label.setText("Done!")
        self.button.setEnabled(True)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

When you run this and click "Start I/O Task", you'll see:

1:1 Coaching & Tutoring for your Python GUIs project
Martin Fitzpatrick Python GUIs Coaching & Training
60 mins ($195) Book Now

1:1 Python GUIs Coaching & Training

Comprehensive code reviewBugfixes & improvements • Maintainability advice and architecture improvements • Design and usability assessment • Suggestions and tips to expand your knowledgePackaging and distribution help for Windows, Mac & Linux • Find out more.

  • The label immediately changes to "Processing..."
  • Progress updates appear as the work happens
  • The button is disabled during the operation and re-enabled when it finishes
  • The GUI remains fully responsive throughout — you can move and resize the window freely

What's happening behind the scenes

When you call self.threadpool.start(worker), the worker's run() method executes on a separate thread. The main thread — where the event loop lives — is free to continue processing events normally. That's why setText("Processing...") takes effect right away: the event loop isn't blocked, so it processes the paint event on the next cycle.

The progress and finished signals are emitted from the worker thread, but Qt automatically delivers them to the slots on the main thread (because signals and slots are thread-safe by default when using queued connections). This means you can safely update widgets from those slots without worrying about thread-safety issues.

For a deeper dive into threading with PyQt6, including handling errors, passing data back from threads, and managing multiple concurrent tasks, see the full Multithreading PyQt6 applications with QThreadPool tutorial.

PyQt6 Crash Course by Martin Fitzpatrick — The important parts of PyQt6 in bite-size chunks

See the course

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

Sequential programming, threads, causality, and the nature of time was written by Martin Fitzpatrick.

Martin Fitzpatrick is the creator of Python GUIs, and has been developing Python/Qt applications for the past 12+ years. He has written a number of popular Python books and provides Python software development & consulting for teams and startups.