Actions in one thread changing data in another

How to communicate between threads and windows in PyQt6
Heads up! You've already completed this tutorial.

I have a main window that starts background threads (e.g., handling GPIO data). From the main window I open secondary windows using buttons. When I press a button in a secondary window, I can't change anything in the background threads. But if I press a button in the main window, everything works. How do I communicate between a secondary window and a thread that was started from the main window?

This is a really common situation when building PyQt6 applications with multiple windows and background threads. The good news is that Qt's signal and slot system is designed to handle exactly this — and it works safely across threads.

The core idea is that your secondary window doesn't need direct access to the thread or the worker object. Instead, it emits a signal, and that signal is connected to a slot on the worker running in the background thread. Qt handles the cross-thread delivery for you.

Let's walk through how to set this up step by step.

Why direct access doesn't work

When you create a background thread from the main window, the objects living in that thread belong to the thread's event loop. If your secondary window tries to call methods on those objects directly, you can run into problems — the call happens in the wrong thread, and Qt isn't designed for that.

The solution is to avoid calling methods directly across threads. Instead, use signals and slots. When a signal is emitted in one thread and connected to a slot in another, Qt automatically queues the call and delivers it safely.

Setting up a background worker

First, let's create a simple worker class that runs in a background thread. This worker simulates handling incoming data (like GPIO data) and also accepts commands from the GUI.

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


class Worker(QObject):
    """A worker that runs in a background thread."""
    data_updated = pyqtSignal(str)

    def __init__(self):
        super().__init__()
        self.running = True
        self.current_value = 0

    @pyqtSlot()
    def run(self):
        """Simulate continuous data handling."""
        while self.running:
            self.current_value += 1
            self.data_updated.emit(f"Data: {self.current_value}")
            time.sleep(1)

    @pyqtSlot(int)
    def set_value(self, value):
        """Receive a new value from the GUI."""
        self.current_value = value
        self.data_updated.emit(f"Value set to: {self.current_value}")

The set_value slot is what we'll trigger from the secondary window. Because it's a slot connected via a signal, Qt will deliver the call on the correct thread.

Creating the secondary window

The secondary window has a button and a spin box. When the user clicks the button, the window emits a signal carrying the new value. The secondary window doesn't know anything about the worker — it just emits a signal.

python
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QPushButton, QSpinBox, QLabel
from PyQt6.QtCore import pyqtSignal


class SecondaryWindow(QWidget):
    """A secondary window that emits a signal when the user sets a value."""
    value_changed = pyqtSignal(int)

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Secondary Window")

        layout = QVBoxLayout()

        self.label = QLabel("Set a new value for the worker:")
        layout.addWidget(self.label)

        self.spinbox = QSpinBox()
        self.spinbox.setRange(0, 1000)
        layout.addWidget(self.spinbox)

        self.button = QPushButton("Send to Worker")
        self.button.clicked.connect(self.send_value)
        layout.addWidget(self.button)

        self.setLayout(layout)

    def send_value(self):
        self.value_changed.emit(self.spinbox.value())

The value_changed signal is the only interface this window exposes. This keeps things clean and decoupled.

Wiring everything together in the main window

The main window is where all the connections happen. It creates the worker, starts the thread, opens the secondary window, and connects the secondary window's signal to the worker's slot.

python
from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QPushButton, QLabel, QWidget
from PyQt6.QtCore import QThread


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Main Window")

        # Set up the UI
        layout = QVBoxLayout()

        self.status_label = QLabel("Waiting for data...")
        layout.addWidget(self.status_label)

        self.open_button = QPushButton("Open Secondary Window")
        self.open_button.clicked.connect(self.open_secondary)
        layout.addWidget(self.open_button)

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

        # Keep a reference to the secondary window
        self.secondary_window = None

        # Set up the background thread and worker
        self.thread = QThread()
        self.worker = Worker()
        self.worker.moveToThread(self.thread)

        # Connect signals
        self.thread.started.connect(self.worker.run)
        self.worker.data_updated.connect(self.update_status)

        # Start the thread
        self.thread.start()

    def update_status(self, text):
        self.status_label.setText(text)

    def open_secondary(self):
        if self.secondary_window is None:
            self.secondary_window = SecondaryWindow()

            # Connect the secondary window's signal to the worker's slot.
            # This is the connection that makes cross-window,
            # cross-thread communication work.
            self.secondary_window.value_changed.connect(self.worker.set_value)

        self.secondary_window.show()

    def closeEvent(self, event):
        self.worker.running = False
        self.thread.quit()
        self.thread.wait()
        super().closeEvent(event)

The line that makes everything work is:

python
self.secondary_window.value_changed.connect(self.worker.set_value)

This connects a signal from the secondary window (running in the main/GUI thread) to a slot on the worker (which has been moved to a background thread). Qt sees that the sender and receiver live in different threads, so it automatically uses a queued connection. The slot call is placed into the background thread's event queue and executed there.

Understanding why the main window worked but the secondary didn't

In the original question, buttons in the main window could affect the background threads, but buttons in a secondary window could not. This usually happens because:

  1. The main window had direct signal-slot connections to the worker (set up when both the worker and the connections were created).
  2. The secondary window was created later, and its signals were never connected to the worker.

The fix is straightforward: when you create the secondary window, connect its signals to the appropriate worker slots, just as you would for the main window. The worker doesn't care where the signal comes from — it just responds to whatever signals are connected to its slots. For more on managing multiple windows in PyQt6, see our tutorial on creating multiple windows.

A note about QThreadPool vs QThread

The original question mentions using QThreadPool. If you're using QRunnable with a QThreadPool, the pattern is slightly different because QRunnable doesn't inherit from QObject and can't have slots directly. In that case, you typically create a separate QObject-based signals class and attach it to your runnable. For a detailed walkthrough of that approach, see Multithreading PyQt6 applications with QThreadPool.

However, for long-running background tasks that need two-way communication with the GUI (like GPIO handling), QThread with moveToThread() is usually a better fit. It gives you a proper event loop in the background thread, which means signals and slots work naturally in both directions.

Complete working example

Here's everything in a single file you can copy, run, and experiment with. If you're new to PyQt6, you may want to start with creating your first window before diving in.

python
import sys
import time

from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import (
    QApplication,
    QLabel,
    QMainWindow,
    QPushButton,
    QSpinBox,
    QVBoxLayout,
    QWidget,
)


class Worker(QObject):
    """A worker that runs in a background thread."""

    data_updated = pyqtSignal(str)

    def __init__(self):
        super().__init__()
        self.running = True
        self.current_value = 0

    @pyqtSlot()
    def run(self):
        """Simulate continuous data handling."""
        while self.running:
            self.current_value += 1
            self.data_updated.emit(f"Data: {self.current_value}")
            time.sleep(1)

    @pyqtSlot(int)
    def set_value(self, value):
        """Receive a new value from the GUI."""
        self.current_value = value
        self.data_updated.emit(f"Value set to: {self.current_value}")


class SecondaryWindow(QWidget):
    """A secondary window that emits a signal when the user sets a value."""

    value_changed = pyqtSignal(int)

    def __init__(self):
        super().__init__()
        self.setWindowTitle("Secondary Window")

        layout = QVBoxLayout()

        self.label = QLabel("Set a new value for the worker:")
        layout.addWidget(self.label)

        self.spinbox = QSpinBox()
        self.spinbox.setRange(0, 1000)
        layout.addWidget(self.spinbox)

        self.button = QPushButton("Send to Worker")
        self.button.clicked.connect(self.send_value)
        layout.addWidget(self.button)

        self.setLayout(layout)

    def send_value(self):
        self.value_changed.emit(self.spinbox.value())


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Main Window")

        # Set up the UI
        layout = QVBoxLayout()

        self.status_label = QLabel("Waiting for data...")
        layout.addWidget(self.status_label)

        self.open_button = QPushButton("Open Secondary Window")
        self.open_button.clicked.connect(self.open_secondary)
        layout.addWidget(self.open_button)

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

        # Keep a reference to the secondary window
        self.secondary_window = None

        # Set up the background thread and worker
        self.thread = QThread()
        self.worker = Worker()
        self.worker.moveToThread(self.thread)

        # Connect signals
        self.thread.started.connect(self.worker.run)
        self.worker.data_updated.connect(self.update_status)

        # Start the thread
        self.thread.start()

    def update_status(self, text):
        self.status_label.setText(text)

    def open_secondary(self):
        if self.secondary_window is None:
            self.secondary_window = SecondaryWindow()
            # Connect the secondary window's signal to the worker's slot
            self.secondary_window.value_changed.connect(
                self.worker.set_value
            )
        self.secondary_window.show()

    def closeEvent(self, event):
        self.worker.running = False
        self.thread.quit()
        self.thread.wait()
        super().closeEvent(event)


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

When you run this, you'll see the main window counting up once per second. Click "Open Secondary Window", enter a number, and click "Send to Worker" — the worker's counter will jump to your chosen value and continue counting from there.

The secondary window communicates with the background thread entirely through signals and slots, with no direct method calls across threads. This pattern scales well — you can connect as many windows as you like to the same worker, or connect one window to multiple workers. As long as you use signals and slots for cross-thread communication, Qt handles the thread safety for you.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Create GUI Applications with Python & Qt6 by Martin Fitzpatrick

(PyQt6 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!

More info Get the book

Martin Fitzpatrick

Actions in one thread changing data in another was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.