I'm running background workers using QThreadPool and trying to update labels in a QDialog with their status. But nothing updates — no prints, no label changes — until after I close the QDialog. Is
exec()blocking everything?
Yes, that's exactly what's happening. When you open a QDialog using exec(), it starts a local event loop that blocks execution of the code that called it. This means any code that comes after the exec() call in your method won't run until the dialog is closed. But more subtly, it can also prevent signals from your background workers from being processed the way you expect.
Let's walk through why this happens and how to fix it.
How exec() works
QDialog has two ways to be shown to the user:
show()— opens the dialog and returns immediately. Your code continues running.exec()— opens the dialog and starts a new, nested event loop. Your code pauses at that line until the dialog is closed.
The exec() approach is designed for situations where you need a result from the user before continuing, such as a confirmation dialog ("Are you sure you want to delete this?"). For that use case, blocking is the desired behavior.
But when you're launching background threads and expecting to update the UI while the dialog is open, exec() works against you.
Here's a simplified version of what's happening in the original code:
def workFunc(self, *argv):
self.ui.resultWindow(self) # <-- if this calls exec(), everything below is paused
self.timer.setInterval(2000)
for job in self.jobs:
# ... launch workers
If resultWindow() internally calls dialog.exec(), then the setInterval and the worker launch code won't run until the dialog is dismissed. That explains why prints and updates only appear after the dialog is closed — the workers weren't even started until that point.
The fix: use show() instead of exec()
If you want a dialog that stays open while background work is running and updates in real time, use show() instead.
Here's a minimal example that demonstrates the difference. We'll create a dialog with a label, launch a background worker, and update the label with progress signals. If you're new to threading in PyQt6, see the full Multithreading PyQt6 applications with QThreadPool tutorial for a detailed introduction.
PyQt/PySide Office Hours 1:1 with Martin Fitzpatrick — Save yourself time and frustration. Get one on one help with your projects. Bring issues, bugs and questions about usability to architecture and maintainability, and leave with solutions.
import sys
import time
from PyQt6.QtCore import (
QObject,
QRunnable,
QThreadPool,
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtWidgets import (
QApplication,
QDialog,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
QWidget,
)
class WorkerSignals(QObject):
progress = pyqtSignal(str)
finished = pyqtSignal()
class Worker(QRunnable):
def __init__(self):
super().__init__()
self.signals = WorkerSignals()
@pyqtSlot()
def run(self):
for i in range(5):
time.sleep(1)
self.signals.progress.emit(f"Step {i + 1} of 5")
self.signals.finished.emit()
class StatusDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Worker Status")
layout = QVBoxLayout()
self.label = QLabel("Waiting...")
layout.addWidget(self.label)
self.setLayout(layout)
def update_status(self, message):
self.label.setText(message)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QDialog + Threads")
self.threadpool = QThreadPool()
button = QPushButton("Start work")
button.clicked.connect(self.start_work)
self.setCentralWidget(button)
self.dialog = None
def start_work(self):
# Create and show the dialog (non-blocking)
self.dialog = StatusDialog(self)
self.dialog.show() # <-- NOT exec()
# Now launch the worker
worker = Worker()
worker.signals.progress.connect(self.dialog.update_status)
worker.signals.finished.connect(self.on_finished)
self.threadpool.start(worker)
def on_finished(self):
if self.dialog:
self.dialog.update_status("Done!")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Run this and click the button. The dialog opens immediately, and the label updates every second as the worker progresses through its steps. The main event loop stays responsive throughout.
If you change self.dialog.show() to self.dialog.exec(), you'll see the problem: the dialog opens, but the label stays stuck on "Waiting..." because the worker isn't started until after the dialog closes.
Why signals weren't updating the dialog
In the original code, Status objects are being updated from inside the worker threads using a plain callback:
class Status():
def __init__(self, ID, callback=lambda callbackparameter: None):
self.status = ""
self.callback = callback
self.id = ID
def update(self, callbackparameter):
self.status = callbackparameter
self.callback(self)
This callback (self.callback(self)) is being called directly from the worker thread. That means postStatus — which updates Qt widgets — is being called from a non-main thread. Modifying widgets from outside the main thread is not safe in Qt and can cause silent failures, crashes, or the updates simply not appearing.
The correct approach is to use Qt signals to communicate from threads back to the main thread. Signals are thread-safe and are automatically delivered to the receiving object's thread (which, for widgets, is the main thread). For a deeper understanding of how signals and slots work in PyQt6, including how they handle cross-thread communication, see our dedicated tutorial.
Here's how to restructure the status updates using signals instead of callbacks:
import sys
import time
from PyQt6.QtCore import (
QObject,
QRunnable,
QThreadPool,
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtWidgets import (
QApplication,
QDialog,
QLabel,
QMainWindow,
QPushButton,
QVBoxLayout,
)
class WorkerSignals(QObject):
status = pyqtSignal(int, str) # slot_id, status_message
finished = pyqtSignal()
class Worker(QRunnable):
def __init__(self, slot_id, data):
super().__init__()
self.slot_id = slot_id
self.data = data
self.signals = WorkerSignals()
@pyqtSlot()
def run(self):
for step in range(3):
time.sleep(1)
self.signals.status.emit(
self.slot_id, f"Processing '{self.data}' - step {step + 1}"
)
self.signals.status.emit(self.slot_id, "Complete")
self.signals.finished.emit()
class StatusDialog(QDialog):
def __init__(self, slot_count, parent=None):
super().__init__(parent)
self.setWindowTitle("Job Status")
layout = QVBoxLayout()
self.labels = []
for i in range(slot_count):
label = QLabel(f"Slot {i}: Idle")
layout.addWidget(label)
self.labels.append(label)
self.setLayout(layout)
def update_slot(self, slot_id, message):
if 0 <= slot_id < len(self.labels):
self.labels[slot_id].setText(f"Slot {slot_id}: {message}")
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Multi-Worker Status Example")
self.threadpool = QThreadPool()
self.jobs = {0: "alpha", 1: "bravo", 2: "charlie"}
button = QPushButton("Run jobs")
button.clicked.connect(self.run_jobs)
self.setCentralWidget(button)
self.dialog = None
self.jobs_done = 0
def run_jobs(self):
self.jobs_done = 0
# Open the status dialog non-blocking
self.dialog = StatusDialog(len(self.jobs), parent=self)
self.dialog.show()
# Launch workers
for slot_id, data in self.jobs.items():
worker = Worker(slot_id, data)
worker.signals.status.connect(self.dialog.update_slot)
worker.signals.finished.connect(self.on_worker_finished)
self.threadpool.start(worker)
def on_worker_finished(self):
self.jobs_done += 1
if self.jobs_done == len(self.jobs):
self.dialog.setWindowTitle("All jobs complete!")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Each worker emits a status signal with its slot ID and a message string. The signal is connected directly to the dialog's update_slot method. Because signals cross thread boundaries safely, the labels update in real time without any direct callback hacking.
When exec() is appropriate
There are cases where exec() makes sense:
- File dialogs — you want to wait for the user to pick a file before continuing.
- Confirmation dialogs — you need a yes/no answer before proceeding.
- Settings dialogs — you want to collect input and only act on it once the dialog is accepted.
In all of these cases, you're intentionally pausing your code flow to wait for a user decision. For more on building these kinds of dialogs, see the PyQt6 Dialogs tutorial. The pattern looks like this:
dialog = QDialog(self)
# ... set up the dialog ...
if dialog.exec():
# User clicked OK / accepted
result = dialog.get_some_value()
else:
# User cancelled
pass
The rule of thumb: if you need to do work while the dialog is open, use show(). If the dialog is a gate that must be resolved before your code continues, exec() is fine.
Summary
Two issues were combining to prevent status updates from appearing:
exec()blocked the code that launched the workers, so nothing happened until the dialog was closed.- Direct callbacks from worker threads to the GUI are unsafe. Qt widgets must only be modified from the main thread.
The solution is to use show() to open the dialog without blocking, and to use Qt signals (not plain Python callbacks) to send updates from workers back to the UI. Signals are delivered safely across thread boundaries, and show() keeps the main event loop free to process them.
Bring Your PyQt/PySide Application to Market — Specialized launch support for scientific and engineering software built using Python & Qt.