I'm building a live stock market GUI with hundreds of prices updating in real-time. Even though I'm using threads for the data fetching, the GUI still freezes because the main thread can't keep up with displaying all the updates. The user can't type in
QLineEditfields or click buttons while prices are being refreshed. What's the best way to handle this?
This is a common situation when working with multithreaded PyQt6 applications. You've done the right thing by moving the data fetching off the main thread — but the bottleneck has shifted to the GUI update step itself. Every time you update a widget (change a label, repaint a table cell), that work happens on the main thread. If you're pushing hundreds of updates at once, the main thread gets blocked processing them all in a single batch, and your interface becomes unresponsive.
The good news is that you can solve this without anything exotic. The approach comes down to two ideas: do updates in small chunks rather than all at once, and throw away data you don't need.
Why the GUI freezes on the main thread
Qt's event loop handles everything on the main thread: painting widgets, processing mouse clicks, handling keyboard input, and executing any slots connected to signals. When your worker thread emits a signal with new data, the connected slot runs on the main thread.
If that slot updates 500 labels in a tight loop, the event loop is blocked for the entire duration. During that time, button clicks, typing, and even window repainting are all queued up and waiting. The result is a GUI that feels frozen, even though the data fetching itself is happening in the background.
The fix is to keep each burst of main-thread work as short as possible. It's fine to interrupt the event loop frequently — even hundreds of times per second — as long as each interruption is brief. Qt will interleave its own events (mouse clicks, repaints, etc.) between yours, and the user won't notice any delay.
Batch your updates into small groups
Instead of emitting a single signal containing all 500 updated prices, send them in small batches — say 5 or 10 at a time. Between each batch, the event loop gets a chance to process user input and repaint the interface.
Here's a minimal example showing the difference. First, let's set up a worker that simulates receiving a stream of price updates and emits them in configurable batch sizes.
import random
import time
from PyQt6.QtCore import QObject, QRunnable, pyqtSignal, pyqtSlot
class WorkerSignals(QObject):
price_update = pyqtSignal(list) # list of (symbol, price) tuples
finished = pyqtSignal()
class PriceWorker(QRunnable):
"""Simulates fetching live price data and emitting updates in batches."""
def __init__(self, symbols, batch_size=5, interval=1.0):
super().__init__()
self.symbols = symbols
self.batch_size = batch_size
self.interval = interval
self.signals = WorkerSignals()
self.is_running = True
@pyqtSlot()
def run(self):
while self.is_running:
# Simulate receiving new prices for all symbols.
updates = [
(symbol, round(random.uniform(10, 500), 2))
for symbol in self.symbols
]
# Emit in small batches instead of all at once.
for i in range(0, len(updates), self.batch_size):
batch = updates[i : i + self.batch_size]
self.signals.price_update.emit(batch)
time.sleep(self.interval)
self.signals.finished.emit()
Each time price_update is emitted, the main thread only needs to update a handful of widgets before returning control to the event loop. This keeps the interface responsive.
Process each batch on the main thread
On the GUI side, the slot that receives each batch updates only the widgets in that batch. Here's how you might wire that up with a grid of labels:
from PyQt6.QtWidgets import (
QApplication,
QGridLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
from PyQt6.QtCore import QThreadPool
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Live Price Updates")
# Generate some fake stock symbols.
self.symbols = [f"SYM{i:03d}" for i in range(200)]
# Create a label for each symbol.
self.price_labels = {}
grid = QGridLayout()
for idx, symbol in enumerate(self.symbols):
row, col = divmod(idx, 10)
label = QLabel(f"{symbol}: --")
grid.addWidget(label, row, col)
self.price_labels[symbol] = label
# Add an input field and button to prove the GUI stays responsive.
self.input_field = QLineEdit()
self.input_field.setPlaceholderText("Type here to test responsiveness...")
self.button = QPushButton("Click me!")
self.button.clicked.connect(
lambda: self.button.setText("Clicked! ✓")
)
layout = QVBoxLayout()
layout.addLayout(grid)
layout.addWidget(self.input_field)
layout.addWidget(self.button)
self.setLayout(layout)
# Start the worker.
self.threadpool = QThreadPool()
self.worker = PriceWorker(
self.symbols, batch_size=5, interval=1.0
)
self.worker.signals.price_update.connect(self.handle_price_batch)
self.threadpool.start(self.worker)
def handle_price_batch(self, batch):
"""Update only the labels in this small batch."""
for symbol, price in batch:
label = self.price_labels.get(symbol)
if label:
label.setText(f"{symbol}: {price}")
def closeEvent(self, event):
self.worker.is_running = False
self.threadpool.waitForDone(2000)
event.accept()
With a batch_size of 5, each call to handle_price_batch updates only 5 labels. For 200 symbols, that's 40 small signal emissions per update cycle — each one fast enough that the event loop can handle mouse clicks and keyboard input in between.
Try changing batch_size to 200 (or the total number of symbols) and you'll notice the GUI becomes less responsive, especially if you try typing while an update cycle is happening.
Throttle updates and discard stale data
When data arrives faster than you can display it, you don't necessarily need to show every single value. For a numeric price display, the user only cares about the latest price. If a new price arrives before the previous one was displayed, the old one is irrelevant.
A simple way to implement this is with a dictionary that acts as a short-term buffer. The worker writes prices into the buffer, and a timer on the main thread reads from it periodically.
import random
import time
from functools import partial
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSlot
from PyQt6.QtWidgets import (
QApplication,
QGridLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
class BufferedPriceWorker(QRunnable):
"""Writes prices into a shared buffer (dict). No signals needed."""
def __init__(self, symbols, buffer, interval=0.1):
super().__init__()
self.symbols = symbols
self.buffer = buffer
self.interval = interval
self.is_running = True
@pyqtSlot()
def run(self):
while self.is_running:
for symbol in self.symbols:
# Simulate a new price arriving.
self.buffer[symbol] = round(random.uniform(10, 500), 2)
time.sleep(self.interval)
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Throttled Price Updates")
self.symbols = [f"SYM{i:03d}" for i in range(200)]
# Shared buffer: worker writes, GUI reads.
self.price_buffer = {}
# Build the UI.
self.price_labels = {}
grid = QGridLayout()
for idx, symbol in enumerate(self.symbols):
row, col = divmod(idx, 10)
label = QLabel(f"{symbol}: --")
grid.addWidget(label, row, col)
self.price_labels[symbol] = label
self.input_field = QLineEdit()
self.input_field.setPlaceholderText("Type here to test responsiveness...")
self.button = QPushButton("Click me!")
self.button.clicked.connect(
lambda: self.button.setText("Clicked! ✓")
)
layout = QVBoxLayout()
layout.addLayout(grid)
layout.addWidget(self.input_field)
layout.addWidget(self.button)
self.setLayout(layout)
# Start the background worker.
self.threadpool = QThreadPool()
self.worker = BufferedPriceWorker(
self.symbols, self.price_buffer, interval=0.1
)
self.threadpool.start(self.worker)
# Timer reads from the buffer and updates the GUI in batches.
self.update_timer = QTimer()
self.update_timer.timeout.connect(self.flush_buffer)
self.update_timer.start(500) # Update display every 500ms
# Track where we are in the symbol list for batched flushing.
self.flush_index = 0
self.flush_batch_size = 10
def flush_buffer(self):
"""Read the latest prices from the buffer and update labels in a batch."""
symbols_to_update = self.symbols[
self.flush_index : self.flush_index + self.flush_batch_size
]
for symbol in symbols_to_update:
price = self.price_buffer.get(symbol)
if price is not None:
self.price_labels[symbol].setText(f"{symbol}: {price}")
self.flush_index += self.flush_batch_size
if self.flush_index >= len(self.symbols):
self.flush_index = 0
def closeEvent(self, event):
self.worker.is_running = False
self.threadpool.waitForDone(2000)
event.accept()
app = QApplication([])
window = MainWindow()
window.show()
app.exec()
In this version, the worker writes prices into self.price_buffer as fast as they arrive (every 0.1 seconds). The GUI reads from the buffer every 500ms, but only updates 10 labels per timer tick. If the worker has written three new prices for SYM042 since the last time the GUI checked, only the latest one gets displayed — the intermediate values are automatically discarded.
This gives you two independent controls:
- Timer interval — how often the GUI refreshes (500ms in this example).
- Batch size — how many widgets get updated per refresh cycle.
You can tune both to find the right balance between visual freshness and responsiveness.
A note on thread safety: In this example, the shared dictionary is being written to by a background thread and read from the main thread. For simple value assignments like
dict[key] = float_value, Python's GIL generally prevents data corruption. For more complex data structures, or if you want to be fully safe, use athreading.Lockaround reads and writes to the buffer. For more on threading best practices, see our guide on multithreading dos and don'ts.
Complete working example
Here's the full batched-signal version as a single file you can copy and run:
"""
Live price update demo — batched signals keep the GUI responsive.
"""
import random
import sys
import time
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import (
QApplication,
QGridLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QWidget,
)
class WorkerSignals(QObject):
price_update = pyqtSignal(list)
finished = pyqtSignal()
class PriceWorker(QRunnable):
def __init__(self, symbols, batch_size=5, interval=1.0):
super().__init__()
self.symbols = symbols
self.batch_size = batch_size
self.interval = interval
self.signals = WorkerSignals()
self.is_running = True
@pyqtSlot()
def run(self):
while self.is_running:
updates = [
(symbol, round(random.uniform(10, 500), 2))
for symbol in self.symbols
]
for i in range(0, len(updates), self.batch_size):
batch = updates[i : i + self.batch_size]
self.signals.price_update.emit(batch)
time.sleep(self.interval)
self.signals.finished.emit()
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Live Price Updates (Batched)")
self.resize(900, 600)
self.symbols = [f"SYM{i:03d}" for i in range(200)]
self.price_labels = {}
grid = QGridLayout()
for idx, symbol in enumerate(self.symbols):
row, col = divmod(idx, 10)
label = QLabel(f"{symbol}: --")
label.setStyleSheet("font-family: monospace;")
grid.addWidget(label, row, col)
self.price_labels[symbol] = label
self.input_field = QLineEdit()
self.input_field.setPlaceholderText(
"Type here while prices update — the GUI stays responsive!"
)
self.button = QPushButton("Click me!")
self.click_count = 0
self.button.clicked.connect(self.on_button_click)
layout = QVBoxLayout()
layout.addLayout(grid)
layout.addWidget(self.input_field)
layout.addWidget(self.button)
self.setLayout(layout)
self.threadpool = QThreadPool()
self.worker = PriceWorker(
self.symbols, batch_size=5, interval=1.0
)
self.worker.signals.price_update.connect(self.handle_price_batch)
self.threadpool.start(self.worker)
def on_button_click(self):
self.click_count += 1
self.button.setText(f"Clicked {self.click_count} time(s) ✓")
def handle_price_batch(self, batch):
for symbol, price in batch:
label = self.price_labels.get(symbol)
if label:
label.setText(f"{symbol}: {price:>8.2f}")
def closeEvent(self, event):
self.worker.is_running = False
self.threadpool.waitForDone(2000)
event.accept()
def main():
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Run this and try typing in the text field or clicking the button while prices are updating. You should find the interface stays smooth and responsive. Try increasing batch_size to 200 to see the difference — you'll notice a slight lag during each update cycle.
Summary
When your GUI freezes during updates from a background thread, the problem is usually that you're doing too much work on the main thread in one go. The two most effective strategies are:
- Batch your updates into small groups. Emit signals with 5–10 items instead of hundreds. The event loop gets breathing room between batches.
- Buffer and discard stale data. If data arrives faster than you can display it, keep only the latest value and update the display on a timer.
Both approaches let you display large amounts of live data without sacrificing the responsiveness your users expect.
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!