Elegant shutdown of running threads

How to gracefully shut down QThreadPool workers when closing your PyQt application
Heads up! You've already completed this tutorial.

I'm using QThreadPool for multithreading in my PyQt application. What happens if the user closes the GUI while one or more threads are still running in the pool? It throws an exception. My idea is to signal from closeEvent, wait for a few seconds while the task finishes, and then quit. Is there a better way to handle this?

When you're running background tasks using QThreadPool and the user closes the application window, things can go wrong. The threads may still be running, trying to emit signals back to widgets that no longer exist. This often results in errors or crashes on shutdown.

The good news is that you can handle this cleanly. In this article, we'll walk through how to gracefully shut down running threads when your PyQt6 application closes.

Why does closing cause problems?

When you close a PyQt6 window, the window and its widgets are destroyed. But if a thread in your QThreadPool is still running, it may try to emit a signal to a slot on one of those destroyed widgets. Qt doesn't like this, and you'll typically see a runtime error or a crash.

The root of the problem: the threads don't know the application is shutting down, so they keep working as if nothing has changed.

The approach: tell your workers to stop

The cleanest way to handle this is to give your worker a way to check whether it should stop running. You do this by setting a flag on the worker, and having the worker check that flag periodically during its work. When the application is closing, you set the flag, and the worker wraps up on its own.

Let's build this up step by step.

A basic worker with a stop flag

Here's a Worker class that extends QRunnable. It has a boolean attribute is_killed that starts as False. Inside the run() method, the worker checks this flag on each iteration of its loop. If is_killed becomes True, the worker exits early.

python
from PyQt6.QtCore import QRunnable, pyqtSlot, pyqtSignal, QObject


class WorkerSignals(QObject):
    progress = pyqtSignal(int)
    finished = pyqtSignal()


class Worker(QRunnable):
    def __init__(self):
        super().__init__()
        self.signals = WorkerSignals()
        self.is_killed = False

    @pyqtSlot()
    def run(self):
        for n in range(100):
            if self.is_killed:
                # Exit the loop early if we've been told to stop.
                return

            self.signals.progress.emit(n + 1)

            # Simulate some work.
            import time
            time.sleep(0.1)

        self.signals.finished.emit()

    def kill(self):
        self.is_killed = True

The kill() method is a simple way to set the flag from outside the worker. You could also set is_killed directly, but having a method keeps things tidy.

The check if self.is_killed inside the loop is what makes this work. Every time the worker goes around the loop, it looks at the flag. If it's been set to True, the worker returns immediately without doing any more work or emitting any more signals.

PyQt/PySide Development Services — Stuck in development hell? I'll help you get your project focused, finished and released. Benefit from years of practical experience releasing software with Python.

Find out More

If you're new to using QThreadPool and QRunnable for multithreading, see our tutorial on Multithreading PyQt6 applications with QThreadPool for a full introduction.

Keeping track of your workers

To be able to stop workers at shutdown, you need to keep references to them. A simple list on your main window works well for this:

python
self.workers = []

Each time you create and start a worker, append it to the list:

python
worker = Worker()
self.threadpool.start(worker)
self.workers.append(worker)

Now your main window has a handle on every worker that's been started.

Stopping workers in closeEvent

Qt provides a closeEvent method on QWidget (and therefore on QMainWindow) that is called whenever the window is about to close. This is the perfect place to tell all your workers to stop and then wait for them to finish.

Here's what that looks like:

python
def closeEvent(self, event):
    # Tell all workers to stop.
    for worker in self.workers:
        worker.kill()

    # Wait for all threads to finish.
    self.threadpool.waitForDone()

    # Accept the close event so the window actually closes.
    event.accept()

self.threadpool.waitForDone() blocks until all threads in the pool have completed. Because we've already set the kill flag on each worker, they will finish their current iteration and exit their run() method, which means waitForDone() won't have to wait long.

If you want to set a maximum wait time (in milliseconds) as a safety net, you can pass it as an argument:

python
self.threadpool.waitForDone(5000)  # Wait up to 5 seconds.

This ensures the application doesn't hang indefinitely if something goes wrong in a worker.

Complete working example

Here's a full application that starts a background worker, displays its progress on a progress bar, and shuts down cleanly when the window is closed:

python
import sys
import time

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


class WorkerSignals(QObject):
    progress = pyqtSignal(int)
    finished = pyqtSignal()


class Worker(QRunnable):
    def __init__(self):
        super().__init__()
        self.signals = WorkerSignals()
        self.is_killed = False

    @pyqtSlot()
    def run(self):
        for n in range(100):
            if self.is_killed:
                return

            self.signals.progress.emit(n + 1)
            time.sleep(0.1)

        self.signals.finished.emit()

    def kill(self):
        self.is_killed = True


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

        self.threadpool = QThreadPool()
        self.workers = []

        self.progress_bar = QProgressBar()
        self.progress_bar.setRange(0, 100)

        self.button = QPushButton("Start Task")
        self.button.clicked.connect(self.start_worker)

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

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

    def start_worker(self):
        worker = Worker()
        worker.signals.progress.connect(self.progress_bar.setValue)
        worker.signals.finished.connect(self.worker_finished)

        self.threadpool.start(worker)
        self.workers.append(worker)

    def worker_finished(self):
        self.progress_bar.setValue(100)

    def closeEvent(self, event):
        for worker in self.workers:
            worker.kill()

        self.threadpool.waitForDone()
        event.accept()


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

Run this, click Start Task, and then close the window while the progress bar is still moving. The application will shut down cleanly — no errors, no crashes.

A few things to keep in mind

Check the flag frequently. The is_killed check only works if the worker looks at it often enough. If your worker does one very long operation (like a single large network request), the flag won't help until that operation completes. In those cases, you might need to restructure the work into smaller chunks, or use a timeout on the blocking operation.

Clean up finished workers. In a long-running application, you might want to remove finished workers from the list to avoid it growing indefinitely. You can connect the finished signal to a method that removes the worker:

python
worker.signals.finished.connect(lambda w=worker: self.workers.remove(w))

Thread safety of the flag. In Python, setting a boolean attribute like self.is_killed = True is effectively atomic due to the GIL (Global Interpreter Lock), so you don't need a mutex or lock for this simple flag pattern. If you were coordinating more complex shared state between threads, you'd want to use QMutex or Python's threading.Lock.

waitForDone() blocks the main thread. While waiting, the UI will be unresponsive. For workers that exit quickly after being killed, this is usually fine. If your workers might take a while to finish even after being killed, consider showing a "shutting down..." message or using a timeout.

This pattern — a kill flag checked inside the worker's loop, combined with waitForDone() in closeEvent — gives you a clean and predictable shutdown for threaded PyQt6 applications. For more on how to transmit data between threads using signals, or how to pause and stop running workers, check out our other threading guides.

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

Elegant shutdown of running threads 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.