0
Python GUIs: Actions in one thread changing data in another — How to communicate between threads and windows in PyQt6
April 29, 2026
Posted 4 hours ago by
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 common problem 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 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 the secondary window and the worker just need access to the same signals, and can then use them to communicate with one another. Qt handles the cross-thread communication automatically. Why doesn't direct access work? When you create a background thread from the main window, you'll often store a reference to that thread on the main window. If that main window then creates a sub-window, it doesn't have any access to the objects on it's parent. Even if it did calling the methods on the thread directly is not usually the right approach. You can access the attributes of a parent window using .parent() but this is a bad habit, because it tightly couples the parts of your application together. If you modify the structure of the parent window, you now need to also edit the sub-window. There are better ways that keep things nicely isolated. 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(fData: {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(fValue 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 connects everything together 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: The main window had direct signal-slot connections to the worker (set up when both the worker and the connections were created). The secondary window was created later, and its signals were never connected to the worker. To solution is to connect its signals to the appropriate worker slots, when you create the secondary window, 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(fData: {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(fValue 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. For an in-depth guide to building Python GUIs with PyQt6 see my book, Create GUI Applications with Python Qt6.
Planet Python
Coverage and analysis from United States of America. All insights are generated by our AI narrative analysis engine.