I created a GUI that uses a worker thread to run calculations and update a progress bar. It works fine for the first run, but if I change the parameters and click the Calculate button again, the GUI stops responding. What's going wrong?
This is one of the most common issues people run into when using QThread with worker objects in PyQt. The good news is that once you understand the cause, the fix is straightforward.
What's Causing the Freeze
The root of the problem is the use of deleteLater() on the worker object after the first run completes. Here's the pattern that causes trouble:
self.worker.finished.connect(self.worker_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
When the worker emits finished, two things happen:
- The thread's event loop is told to quit.
- The worker object is scheduled for deletion.
After this, the worker no longer exists and the thread is no longer running. So when you click the Calculate button a second time and emit a signal to the worker, there's nothing on the other end to receive it. The signal goes nowhere, the GUI sits waiting for a response that will never come, and your application appears frozen.
The same issue applies if you also call deleteLater() on the thread itself:
self.worker_thread.finished.connect(self.worker_thread.deleteLater)
After the first run, both the thread and the worker are gone.
The Fix: Don't Delete What You Need to Reuse
If you want to reuse the same worker and thread across multiple button clicks, you should not delete them after each run. Instead, keep the thread running and simply let the worker sit idle between tasks.
Remove these lines:
self.worker.finished.connect(self.worker.deleteLater)
self.worker_thread.finished.connect(self.worker_thread.deleteLater)
You should also reconsider whether you need to quit the thread after each run. If the thread is going to be reused, quitting it means you'll need to restart it before sending the next task — and restarting a QThread that has already quit can be error-prone.
A cleaner approach is to leave the thread running for the lifetime of the window, and only quit it when the window closes.
A Clean Reusable Worker Pattern
Let's walk through a complete example that demonstrates a working, reusable worker thread setup. This avoids the freeze-on-second-click problem entirely.
Setting Up the Worker
The worker lives in a separate thread and does some work when it receives a signal. When it finishes, it emits finished — but we don't delete it or quit the thread. This approach uses signals and slots to communicate safely between threads.
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
import time
class Worker(QObject):
progress = pyqtSignal(int)
result = pyqtSignal(str)
finished = pyqtSignal()
@pyqtSlot(str, str)
def do_work(self, param1, param2):
"""Simulate a long-running calculation."""
for i in range(1, 101):
time.sleep(0.03) # Simulate work
self.progress.emit(i)
self.result.emit(f"Done with {param1} and {param2}")
self.finished.emit()
Setting Up the Main Window
In the main window, we create the worker and thread once during __init__ and reuse them for every button click. The thread stays alive until the window is closed.
import sys
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QPushButton, QProgressBar, QLabel, QLineEdit,
)
from PyQt6.QtCore import QThread, pyqtSignal
class MainWindow(QMainWindow):
work_requested = pyqtSignal(str, str)
def __init__(self):
super().__init__()
self.setWindowTitle("Reusable Worker Thread")
layout = QVBoxLayout()
self.input1 = QLineEdit("alpha")
self.input2 = QLineEdit("beta")
self.button = QPushButton("Calculate")
self.progress_bar = QProgressBar()
self.status_label = QLabel("Ready")
layout.addWidget(self.input1)
layout.addWidget(self.input2)
layout.addWidget(self.button)
layout.addWidget(self.progress_bar)
layout.addWidget(self.status_label)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
# Set up the worker and thread.
self.worker = Worker()
self.worker_thread = QThread()
self.worker.moveToThread(self.worker_thread)
# Connect signals.
self.work_requested.connect(self.worker.do_work)
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.result.connect(self.on_result)
self.worker.finished.connect(self.on_finished)
# Start the thread. It will stay running, waiting for work.
self.worker_thread.start()
self.button.clicked.connect(self.start_calculation)
def start_calculation(self):
self.button.setEnabled(False)
self.status_label.setText("Calculating...")
self.progress_bar.setValue(0)
param1 = self.input1.text()
param2 = self.input2.text()
self.work_requested.emit(param1, param2)
def on_result(self, result_text):
self.status_label.setText(result_text)
def on_finished(self):
self.button.setEnabled(True)
def closeEvent(self, event):
"""Clean up the thread when the window closes."""
self.worker_thread.quit()
self.worker_thread.wait()
super().closeEvent(event)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
You can click Calculate, wait for it to finish, change the inputs, and click Calculate again — it works every time.
Why This Works
The worker thread starts once and stays running. Its event loop is always active, waiting for incoming signals. Each time you emit work_requested, the signal crosses the thread boundary, and the worker's do_work slot is invoked in the worker thread. When the work finishes, finished is emitted, re-enabling the button — but the worker and thread remain intact, ready for the next task.
There's no deleteLater, no quitting and restarting the thread. The thread lives for the entire lifetime of the window, and is cleanly shut down in closeEvent.
What If You Really Need a Fresh Worker Each Time?
Sometimes your worker holds state that you want to reset between runs, or you want a truly fresh start each time. In that case, you can create a new worker and thread for each run — but you need to be careful to set everything up from scratch each time.
Here's what that pattern looks like:
def start_calculation(self):
self.button.setEnabled(False)
self.status_label.setText("Calculating...")
self.progress_bar.setValue(0)
# Create fresh worker and thread for each run.
self.worker = Worker()
self.worker_thread = QThread()
self.worker.moveToThread(self.worker_thread)
# Connect signals.
self.work_requested.connect(self.worker.do_work)
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.result.connect(self.on_result)
self.worker.finished.connect(self.on_finished)
# Clean up after this run completes.
self.worker.finished.connect(self.worker_thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.worker_thread.finished.connect(self.worker_thread.deleteLater)
self.worker_thread.start()
param1 = self.input1.text()
param2 = self.input2.text()
self.work_requested.emit(param1, param2)
In this version, deleteLater is fine because you're creating everything fresh on the next click. You won't try to reuse a deleted object.
One thing to watch: if work_requested is a persistent signal on the main window, connecting it in every call to start_calculation will accumulate duplicate connections. You can avoid this by disconnecting the signal before reconnecting, or by passing the parameters directly through the thread setup rather than using a signal.
If you're looking for an alternative threading approach that simplifies some of this setup, consider using QThreadPool and QRunnable instead — it handles thread lifecycle management for you.
Summary
| Approach | When to use it |
|---|---|
| Keep the thread running | When you want to reuse the worker across multiple clicks. Simpler and more efficient. |
| Create fresh worker and thread | When you need a clean slate each time. Requires careful setup and teardown. |
The persistent thread approach is generally easier to get right and is the recommended pattern for most cases. Keep the thread alive, keep the worker alive, and let signals do the work of passing data back and forth.
Complete Working Example
Here's the full, copy-and-run example using the persistent thread approach:
import sys
import time
from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import (
QApplication,
QLabel,
QLineEdit,
QMainWindow,
QProgressBar,
QPushButton,
QVBoxLayout,
QWidget,
)
class Worker(QObject):
progress = pyqtSignal(int)
result = pyqtSignal(str)
finished = pyqtSignal()
@pyqtSlot(str, str)
def do_work(self, param1, param2):
"""Simulate a long-running calculation."""
for i in range(1, 101):
time.sleep(0.03)
self.progress.emit(i)
self.result.emit(f"Done with {param1} and {param2}")
self.finished.emit()
class MainWindow(QMainWindow):
work_requested = pyqtSignal(str, str)
def __init__(self):
super().__init__()
self.setWindowTitle("Reusable Worker Thread")
layout = QVBoxLayout()
self.input1 = QLineEdit("alpha")
self.input2 = QLineEdit("beta")
self.button = QPushButton("Calculate")
self.progress_bar = QProgressBar()
self.status_label = QLabel("Ready")
layout.addWidget(self.input1)
layout.addWidget(self.input2)
layout.addWidget(self.button)
layout.addWidget(self.progress_bar)
layout.addWidget(self.status_label)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
# Set up the worker and thread once.
self.worker = Worker()
self.worker_thread = QThread()
self.worker.moveToThread(self.worker_thread)
# Connect signals.
self.work_requested.connect(self.worker.do_work)
self.worker.progress.connect(self.progress_bar.setValue)
self.worker.result.connect(self.on_result)
self.worker.finished.connect(self.on_finished)
# Start the thread — it stays running until the window closes.
self.worker_thread.start()
self.button.clicked.connect(self.start_calculation)
def start_calculation(self):
self.button.setEnabled(False)
self.status_label.setText("Calculating...")
self.progress_bar.setValue(0)
param1 = self.input1.text()
param2 = self.input2.text()
self.work_requested.emit(param1, param2)
def on_result(self, result_text):
self.status_label.setText(result_text)
def on_finished(self):
self.button.setEnabled(True)
def closeEvent(self, event):
self.worker_thread.quit()
self.worker_thread.wait()
super().closeEvent(event)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Run this, click Calculate, let it finish, change the text inputs, and click Calculate again. It works reliably every time. The worker thread stays alive in the background, ready for the next task, and is only shut down when you close the window.
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.