If you're building a PyQt6 application that receives data at high frequency — say via MQTT — and you're updating a QTableWidget from a background thread, you've probably already discovered the hard way that your application crashes with a cryptic "Python has stopped working" message.
This is one of the most common pitfalls in PyQt6 development, and it has a straightforward fix. In this article, we'll walk through what causes the crash and how to restructure your code using Qt signals so everything runs smoothly.
The Problem: Updating Widgets from a Background Thread
Here's a typical scenario. You have an MQTT client receiving messages every millisecond. Each message contains a row of data that needs to go into a QTableWidget. To avoid blocking the GUI, you've moved the data processing into a QRunnable or another thread, and you're using a multiprocessing.Queue to pass data between them.
Your update code might look something like this, running inside a QRunnable:
def UpdateTable(self):
while True:
if not self.Que.empty():
self.table.setRowCount(1000)
WholeData = self.Que.get()
for item in WholeData:
if len(item) > 0:
RawData = item.split(',')
self.table.insertRow(0)
for i in range(1, 4):
self.table.setItem(0, i - 1, QTableWidgetItem(str(RawData[i])))
At 100ms intervals this might seem to work fine. But crank it up to 1ms and the application crashes — sometimes immediately, sometimes after a few seconds.
The crash isn't about speed or the data itself. The problem is that you're modifying Qt widgets from a thread that isn't the main GUI thread. Qt widgets are not thread-safe. Any attempt to create, modify, or interact with widgets from outside the main thread can cause a segmentation fault, which shows up as that unhelpful "Python has stopped working" dialog.
This is a firm rule in Qt: all widget operations must happen on the main (GUI) thread. There are no exceptions and no workarounds — you have to respect this boundary.
The Fix: Use Signals to Send Data to the Main Thread
The solution is to keep your data collection in the background thread, but send the data back to the main thread using Qt's signal/slot mechanism. The main thread then handles the actual widget updates.
Here's how to restructure things.
Create GUI Applications with Python & Qt5 by Martin Fitzpatrick — (PyQt5 Edition) The hands-on guide to making apps with Python — Save time and build better with this book. Over 15K copies sold.
Define a Signals Class
Qt signals need to be defined on a QObject subclass. Since QRunnable doesn't inherit from QObject, we create a small helper class:
from PyQt6.QtCore import QObject, pyqtSignal
class WorkerSignals(QObject):
data = pyqtSignal(list)
This defines a signal called data that will carry a Python list when emitted.
Create a Worker That Emits Data
Your background worker collects data from the queue and emits it via the signal, rather than touching any widgets directly:
from PyQt6.QtCore import QRunnable
class DataWorker(QRunnable):
def __init__(self, queue):
super().__init__()
self.queue = queue
self.signals = WorkerSignals()
def run(self):
while True:
if not self.queue.empty():
whole_data = self.queue.get()
self.signals.data.emit(whole_data)
Notice that the worker never touches a widget. It just emits the data. The actual table update happens elsewhere — on the main thread. For a deeper dive into using QRunnable and QThreadPool for background tasks, see our guide to multithreading PyQt6 applications.
Handle the Update on the Main Thread
In your main window class, define a method that receives the data and updates the table. Connect the worker's signal to this method:
from PyQt6.QtWidgets import QMainWindow, QTableWidget, QTableWidgetItem
from PyQt6.QtCore import QThreadPool
class MainWindow(QMainWindow):
def __init__(self, queue):
super().__init__()
self.table = QTableWidget()
self.table.setColumnCount(11)
self.table.setRowCount(1000)
self.setCentralWidget(self.table)
self.threadpool = QThreadPool()
worker = DataWorker(queue)
worker.signals.data.connect(self.update_table)
self.threadpool.start(worker)
def update_table(self, whole_data):
for item in whole_data:
if len(item) == 0:
continue
raw_data = item.strip().split(',')
if len(raw_data) < 4:
continue
self.table.insertRow(0)
# Set the first 3 columns (indices 1, 2, 3 from raw data)
for i in range(1, 4):
self.table.setItem(
0, i - 1, QTableWidgetItem(str(raw_data[i]))
)
# Set remaining columns based on the count in raw_data[3]
count = int(raw_data[3])
for i in range(count):
self.table.setItem(
0, i + 3, QTableWidgetItem(str(raw_data[i + 4]))
)
# Keep the table at 1000 rows max
while self.table.rowCount() > 1000:
self.table.removeRow(self.table.rowCount() - 1)
Because update_table is connected to the signal via connect(), Qt automatically ensures it runs on the main thread — even though the signal was emitted from a background thread. This is exactly what makes signals safe for cross-thread communication.
Complete Working Example
Here's a self-contained example that simulates incoming data using a multiprocessing.Queue and displays it in a QTableWidget. Instead of MQTT, a separate process generates fake data every few milliseconds:
import sys
import time
import multiprocessing
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QTableWidget, QTableWidgetItem,
)
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal
class WorkerSignals(QObject):
"""Defines signals available from a running worker."""
data = pyqtSignal(list)
class DataWorker(QRunnable):
"""
Worker that reads from a multiprocessing Queue
and emits data via a signal.
"""
def __init__(self, queue):
super().__init__()
self.queue = queue
self.signals = WorkerSignals()
def run(self):
while True:
if not self.queue.empty():
whole_data = self.queue.get()
self.signals.data.emit(whole_data)
else:
# Small sleep to avoid busy-waiting when the queue is empty.
time.sleep(0.001)
class MainWindow(QMainWindow):
MAX_ROWS = 1000
def __init__(self, queue):
super().__init__()
self.setWindowTitle("QTableWidget with Threaded Updates")
self.resize(800, 600)
self.table = QTableWidget()
self.table.setColumnCount(11)
self.table.setRowCount(0)
self.table.setHorizontalHeaderLabels(
["Timestamp", "Value", "Count"]
+ [f"D{i}" for i in range(8)]
)
self.setCentralWidget(self.table)
self.threadpool = QThreadPool()
worker = DataWorker(queue)
worker.signals.data.connect(self.update_table)
self.threadpool.start(worker)
def update_table(self, whole_data):
"""
Receives a list of comma-separated strings and inserts
each one as a new row at the top of the table.
"""
for item in whole_data:
item = item.strip()
if len(item) == 0:
continue
raw_data = item.split(',')
if len(raw_data) < 4:
continue
# Insert a new row at the top
self.table.insertRow(0)
# Columns 0-2: timestamp, value, count
for col in range(1, 4):
if col < len(raw_data):
self.table.setItem(
0, col - 1, QTableWidgetItem(raw_data[col])
)
# Remaining columns: data points
count = int(raw_data[3])
for i in range(count):
idx = i + 4
if idx < len(raw_data):
self.table.setItem(
0, i + 3, QTableWidgetItem(raw_data[idx])
)
# Trim the table if it exceeds the maximum number of rows
while self.table.rowCount() > self.MAX_ROWS:
self.table.removeRow(self.table.rowCount() - 1)
def data_producer(queue):
"""
Simulates an MQTT-like data source that puts messages
into a multiprocessing Queue.
"""
import random
counter = 0
while True:
timestamp = int(time.time())
value = random.randint(100, 999)
num_points = 8
points = [random.randint(1, 100) for _ in range(num_points)]
points_str = ",".join(str(p) for p in points)
line = f"c,{timestamp},{value},{num_points},{points_str}"
queue.put([line])
counter += 1
time.sleep(0.005) # 5ms interval — adjust to test
if __name__ == "__main__":
data_queue = multiprocessing.Queue()
# Start the data producer in a separate process
producer = multiprocessing.Process(
target=data_producer, args=(data_queue,), daemon=True
)
producer.start()
app = QApplication(sys.argv)
window = MainWindow(data_queue)
window.show()
sys.exit(app.exec())
Run this and you'll see rows appearing at the top of the table, with older rows being pushed down and eventually removed when the table exceeds 1000 rows.
A Note on 1ms Update Rates
Even with the threading issue fixed, updating a table widget every single millisecond is extremely aggressive. Each call to insertRow and setItem triggers layout recalculations and repaints. At 1000 updates per second, the GUI will struggle to keep up regardless of how you structure the code.
A more practical approach is to batch your updates. Instead of updating the table for every single message, collect messages over a short window (say 50–100ms) and then insert them all at once. You can use QTimer for this:
from PyQt6.QtCore import QTimer
class MainWindow(QMainWindow):
MAX_ROWS = 1000
def __init__(self, queue):
super().__init__()
self.setWindowTitle("QTableWidget with Batched Updates")
self.resize(800, 600)
self.table = QTableWidget()
self.table.setColumnCount(11)
self.table.setRowCount(0)
self.table.setHorizontalHeaderLabels(
["Timestamp", "Value", "Count"]
+ [f"D{i}" for i in range(8)]
)
self.setCentralWidget(self.table)
# Buffer to hold incoming data
self.data_buffer = []
self.threadpool = QThreadPool()
worker = DataWorker(queue)
worker.signals.data.connect(self.buffer_data)
self.threadpool.start(worker)
# Flush the buffer every 100ms
self.update_timer = QTimer()
self.update_timer.timeout.connect(self.flush_buffer)
self.update_timer.start(100)
def buffer_data(self, whole_data):
"""Store incoming data without touching the table yet."""
self.data_buffer.extend(whole_data)
def flush_buffer(self):
"""Insert all buffered rows into the table at once."""
if not self.data_buffer:
return
# Temporarily disable updates to avoid repainting per-row
self.table.setUpdatesEnabled(False)
for item in self.data_buffer:
item = item.strip()
if len(item) == 0:
continue
raw_data = item.split(',')
if len(raw_data) < 4:
continue
self.table.insertRow(0)
for col in range(1, 4):
if col < len(raw_data):
self.table.setItem(
0, col - 1, QTableWidgetItem(raw_data[col])
)
count = int(raw_data[3])
for i in range(count):
idx = i + 4
if idx < len(raw_data):
self.table.setItem(
0, i + 3, QTableWidgetItem(raw_data[idx])
)
self.data_buffer.clear()
# Trim excess rows
while self.table.rowCount() > self.MAX_ROWS:
self.table.removeRow(self.table.rowCount() - 1)
self.table.setUpdatesEnabled(True)
The call to setUpdatesEnabled(False) before the batch and setUpdatesEnabled(True) afterward prevents the table from repainting after every single row insertion. This makes a huge difference when inserting many rows at once.
With batching, your MQTT client can still receive messages at whatever rate it needs to. The table just updates at a pace the GUI can handle comfortably.
Summary
When your QTableWidget crashes during fast updates, the cause is almost always the same: widgets are being modified from a background thread. Qt requires all widget interactions to happen on the main GUI thread.
The fix follows a simple pattern:
- Keep your data collection in a background thread (
QRunnable,QThread, or a separate process). - Send data to the main thread using a Qt signal.
- Connect that signal to a slot on your main window that performs the actual table update.
- For very high data rates, batch your updates using a
QTimerandsetUpdatesEnabled()to keep the GUI responsive.
This pattern works for any situation where background data needs to reach the GUI — not just QTableWidget, but any widget at all. For larger datasets where you need more control over how data is displayed, consider using QTableView with a custom model instead of QTableWidget, which gives you better performance and flexibility through Qt's Model/View architecture.