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:
self.label.setText("Processing...")
self.do_long_io_operation()
Here's what actually happens:
setText("Processing...")schedules a paint event. It marks the label as needing a redraw, but the redraw doesn't happen yet.do_long_io_operation()runs immediately — and blocks the event loop for the entire duration.- 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():
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:
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:
- 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