When you're running long-running tasks in a background thread using PyQt6's QThreadPool and QRunnable, you'll quickly run into a common question: how do I stop or pause a thread that's already running?
Unlike QThread, the QRunnable class doesn't come with built-in mechanisms for stopping or pausing. There's no .stop() method or .pause() signal you can call. But with a small amount of extra code, you can add this control yourself using simple Python flags that your worker checks periodically.
In this tutorial, we'll walk through how to build a threaded worker that you can start, stop, and pause/resume using buttons in your PyQt6 GUI.
- The problem: QRunnable has no built-in stop or pause
- Creating a basic QRunnable worker with a loop
- Adding stop and pause controls to QRunnable
- Complete example: PyQt6 GUI with start, stop, and pause buttons
- How the start, stop, and pause signals work
- Important considerations for threaded PyQt6 workers
- Adapting this pattern for your own PyQt6 application
The problem: QRunnable has no built-in stop or pause
If you've followed the Multithreading PyQt6 applications with QThreadPool tutorial, you'll know how to run background tasks using a QRunnable worker and QThreadPool. But once a worker is running, you don't have direct control over it — the thread pool manages execution for you.
So if your worker is doing something in a loop (processing data, polling a device, running a simulation), how do you tell it to stop? Or pause?
The answer is straightforward: use a shared flag — a simple boolean variable — that your worker checks on each iteration of its loop. When you want the worker to stop, you set the flag from your main thread, and the worker sees it and exits gracefully.
Creating a basic QRunnable worker with a loop
Let's start with a simple QRunnable worker that counts in a loop, emitting progress as it goes. This is the kind of long-running task you might want to control.
import time
from PyQt6.QtCore import (
QObject,
QRunnable,
QThreadPool,
pyqtSignal,
pyqtSlot,
)
class WorkerSignals(QObject):
"""Signals available from a running worker thread."""
progress = pyqtSignal(int)
finished = pyqtSignal()
class Worker(QRunnable):
"""Worker thread for running background tasks."""
def __init__(self):
super().__init__()
self.signals = WorkerSignals()
@pyqtSlot()
def run(self):
for n in range(100):
time.sleep(0.1)
self.signals.progress.emit(n + 1)
self.signals.finished.emit()
This worker counts from 1 to 100, pausing briefly between each step. It emits a progress signal so the GUI can update, and a finished signal when it's done.
The problem? Once you start it, there's no way to stop it early or pause it mid-way. Let's fix that.
Adding stop and pause controls to QRunnable
To control the worker, we add two boolean attributes: is_paused and is_stopped. The worker checks these flags on every iteration of its loop.
Bring Your PyQt/PySide Application to Market — Specialized launch support for scientific and engineering software built using Python & Qt.
class Worker(QRunnable):
"""Worker thread with stop and pause controls."""
def __init__(self):
super().__init__()
self.signals = WorkerSignals()
self.is_paused = False
self.is_stopped = False
@pyqtSlot()
def run(self):
for n in range(100):
# Check if we've been told to stop.
if self.is_stopped:
break
# If paused, wait here until resumed (or stopped).
while self.is_paused:
time.sleep(0.1)
if self.is_stopped:
break
time.sleep(0.1)
self.signals.progress.emit(n + 1)
self.signals.finished.emit()
def stop(self):
"""Request the worker to stop."""
self.is_stopped = True
def pause(self):
"""Pause the worker."""
self.is_paused = True
def resume(self):
"""Resume the worker."""
self.is_paused = False
Let's walk through what's happening here:
-
self.is_stopped: When set toTrue, the worker breaks out of its main loop on the next iteration. Thefinishedsignal is still emitted so the GUI knows the worker is done. -
self.is_paused: When set toTrue, the worker enters a tight innerwhileloop that just sleeps. It stays there untilis_pausedis set back toFalse(via theresume()method). We also checkis_stoppedinside the pause loop, so you can stop a paused worker without needing to resume it first. -
stop(),pause(),resume(): These are convenience methods that set the flags. You call them from your main thread (e.g., from button click handlers).
The time.sleep(0.1) inside the pause loop keeps the thread from spinning at full speed while it waits. Without it, the paused thread would consume CPU unnecessarily.
Complete example: PyQt6 GUI with start, stop, and pause buttons
Now let's build a small PyQt6 window with buttons to Start, Pause/Resume, and Stop the worker, plus a progress bar to see what's happening.
import sys
import time
from PyQt6.QtCore import (
QObject,
QRunnable,
QThreadPool,
pyqtSignal,
pyqtSlot,
)
from PyQt6.QtWidgets import (
QApplication,
QHBoxLayout,
QMainWindow,
QProgressBar,
QPushButton,
QVBoxLayout,
QWidget,
)
class WorkerSignals(QObject):
"""Signals available from a running worker thread."""
progress = pyqtSignal(int)
finished = pyqtSignal()
class Worker(QRunnable):
"""Worker thread with stop and pause controls."""
def __init__(self):
super().__init__()
self.signals = WorkerSignals()
self.is_paused = False
self.is_stopped = False
@pyqtSlot()
def run(self):
for n in range(100):
if self.is_stopped:
break
while self.is_paused:
time.sleep(0.1)
if self.is_stopped:
break
time.sleep(0.1)
self.signals.progress.emit(n + 1)
self.signals.finished.emit()
def stop(self):
"""Request the worker to stop."""
self.is_stopped = True
def pause(self):
"""Pause the worker."""
self.is_paused = True
def resume(self):
"""Resume the worker."""
self.is_paused = False
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Thread Control Example")
self.threadpool = QThreadPool()
self.worker = None
# Create widgets.
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
self.start_button = QPushButton("Start")
self.pause_button = QPushButton("Pause")
self.stop_button = QPushButton("Stop")
# Disable pause and stop until a worker is running.
self.pause_button.setEnabled(False)
self.stop_button.setEnabled(False)
# Connect buttons.
self.start_button.clicked.connect(self.start_worker)
self.pause_button.clicked.connect(self.pause_worker)
self.stop_button.clicked.connect(self.stop_worker)
# Layout.
button_layout = QHBoxLayout()
button_layout.addWidget(self.start_button)
button_layout.addWidget(self.pause_button)
button_layout.addWidget(self.stop_button)
layout = QVBoxLayout()
layout.addWidget(self.progress_bar)
layout.addLayout(button_layout)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def start_worker(self):
"""Start a new worker thread."""
self.worker = Worker()
self.worker.signals.progress.connect(self.update_progress)
self.worker.signals.finished.connect(self.worker_finished)
# Update button states.
self.start_button.setEnabled(False)
self.pause_button.setEnabled(True)
self.stop_button.setEnabled(True)
self.pause_button.setText("Pause")
# Reset progress bar.
self.progress_bar.setValue(0)
self.threadpool.start(self.worker)
def pause_worker(self):
"""Toggle pause/resume on the worker."""
if self.worker is None:
return
if self.worker.is_paused:
self.worker.resume()
self.pause_button.setText("Pause")
else:
self.worker.pause()
self.pause_button.setText("Resume")
def stop_worker(self):
"""Stop the running worker."""
if self.worker is None:
return
self.worker.stop()
def update_progress(self, value):
"""Update the progress bar."""
self.progress_bar.setValue(value)
def worker_finished(self):
"""Handle worker completion."""
self.start_button.setEnabled(True)
self.pause_button.setEnabled(False)
self.stop_button.setEnabled(False)
self.pause_button.setText("Pause")
self.worker = None
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Run this and you'll see a window with a progress bar and three buttons. Click Start and the progress bar begins filling. Click Pause and it freezes in place — click it again (now labeled Resume) and it continues. Click Stop and the worker exits early.

How the start, stop, and pause signals work
Let's trace through what happens when you interact with the buttons:
Starting: start_worker() creates a new Worker instance, connects its signals, and hands it to the thread pool. The pool assigns it to an available thread and calls run().
Pausing: pause_worker() calls self.worker.pause(), which sets is_paused = True. On the next loop iteration, the worker enters the inner while self.is_paused loop and just sleeps. The progress bar stops updating. When you click the button again, resume() sets is_paused = False, and the worker continues its main loop from where it left off.
Stopping: stop_worker() calls self.worker.stop(), which sets is_stopped = True. On the next iteration (or inside the pause loop), the worker sees this flag and breaks out. The finished signal fires, and worker_finished() resets the GUI.
Important considerations for threaded PyQt6 workers
Flags are checked between iterations. The worker only checks is_stopped and is_paused at the top of each loop iteration. If a single iteration takes a long time (e.g., a heavy computation or a blocking network call), there will be a delay before the worker responds to your stop or pause request. This is a cooperative approach — the worker has to agree to stop.
Auto-delete behavior. By default, QRunnable objects are auto-deleted by the thread pool after run() completes. If you need to keep a reference to the worker after it finishes (to read results, for example), call self.setAutoDelete(False) in the worker's __init__. In our example, we clear the reference in worker_finished(), so auto-delete is fine.
Thread safety. Setting a simple boolean from one thread and reading it from another works reliably in practice with CPython, thanks to the GIL (Global Interpreter Lock). For more complex shared state, you'd want to use proper synchronization primitives like QMutex. But for boolean flags like these, you're safe.
Don't force-kill threads. You might be tempted to look for a way to forcefully terminate a thread. Resist that temptation. Force-killing threads can leave your application in an inconsistent state — locks held, resources leaked, signals half-emitted. The cooperative flag-based approach shown here is the right way to do it.
Adapting this pattern for your own PyQt6 application
To adapt this pattern for your own application, replace the for loop and time.sleep() in the worker's run() method with your actual task. The pattern works well for any loop-based workload:
- Processing items from a list or queue
- Polling an external device or API
- Running a simulation step by step
- Reading or writing data in chunks
Just make sure to check is_stopped and is_paused at a reasonable frequency within your loop. If your loop body does something that takes a few seconds, consider adding the check at multiple points within the loop body.
@pyqtSlot()
def run(self):
for item in self.data:
if self.is_stopped:
break
while self.is_paused:
time.sleep(0.1)
if self.is_stopped:
break
# Do your actual work here.
result = process(item)
self.signals.progress.emit(result)
self.signals.finished.emit()
That's all there is to it. With a couple of boolean flags and some cooperative checking, you get full start/stop/pause control over your background workers — and your PyQt6 GUI stays responsive the whole time.