If you have a long-running task, or your application needs to do some work at startup, it's common to want to show some kind of loading animation. However, if you've tried to do this there are a few gotchas to watch out for. In this short tutorial we'll look at how to get this working reliably in your apps.
The problem
Take a look at this method, which could be called during your main window's __init__. This method displays a message, creates an animation GIF using QMovie, starts the animation and then starts the worker.
def startup(self):
self.label_loading.setHidden(False)
self.gif_loading = QMovie('ui/loading.gif')
self.label_loading.setMovie(self.gif_loading)
self.gif_loading.start()
worker = Worker(self.PreparingMyApp)
worker.signals.result.connect(self.print_output)
worker.signals.finished.connect(self.thread_complete)
worker.signals.progress.connect(self.progress_fn)
self.threadpool.start(worker)
self.gif_loading.stop() # <-- This runs immediately!
self.label_loading.setHidden(True) # <-- So does this!
If you run this, you'll notice that the animation stops immediately (or
rather doesn't even start). That's because the .stop() method is called immediately after self.threadpool.start(worker).
Remember, the reason we put the work to do in a separate worker thread is so that it doesn't block the main UI. Starting a worker on the thread pool is non-blocking — it kicks off the work in the background and returns right away. So the very next lines execute before the background task has even begun, stopping your animation before anyone can see it.
Using the worker's finish signal
The solution is to move the cleanup code - stopping the GIF and hiding the label - into a slot connected to worker's finished signal. This way, the GIF keeps playing for as long as the background task is running, and stops only when the task completes.
Here's the corrected version:
def startup(self):
# Show the loading label and start the animated GIF
self.label_loading.setHidden(False)
self.gif_loading = QMovie('ui/loading.gif')
self.label_loading.setMovie(self.gif_loading)
self.gif_loading.start()
# Set up the worker
worker = Worker(self.PreparingMyApp)
worker.signals.result.connect(self.print_output)
worker.signals.finished.connect(self.handle_finished) # <-- connect to cleanup
worker.signals.progress.connect(self.progress_fn)
# Start the worker
self.threadpool.start(worker)
# Don't stop the GIF here! Let it run until the worker is done.
def handle_finished(self):
"""Called when the PreparingMyApp worker completes."""
self.gif_loading.stop()
self.label_loading.setHidden(True)
Now the GIF will animate happily while PreparingMyApp does its work on a background thread, and it will stop and hide itself once the work is done.
A complete working example
Let's put together a self-contained example you can copy and run. You'll need any animated GIF file; save one as loading.gif in the same folder as the script.
import sys
import time
import traceback
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QPushButton, QLabel
)
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QMovie
class WorkerSignals(QObject):
"""Signals available from a running worker thread."""
finished = pyqtSignal()
error = pyqtSignal(tuple)
result = pyqtSignal(object)
progress = pyqtSignal(int)
class Worker(QRunnable):
"""
Worker thread. Runs a function on a separate thread via QThreadPool.
"""
def __init__(self, fn, *args, **kwargs):
super().__init__()
self.fn = fn
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
self.kwargs['progress_callback'] = self.signals.progress
@pyqtSlot()
def run(self):
try:
result = self.fn(*self.args, **self.kwargs)
except Exception:
traceback.print_exc()
exctype, value = sys.exc_info()[:2]
self.signals.error.emit((exctype, value, traceback.format_exc()))
else:
self.signals.result.emit(result)
finally:
self.signals.finished.emit()
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Loading GIF Example")
self.resize(400, 300)
# Set up a simple layout
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QVBoxLayout(central_widget)
# A label to show the animated GIF
self.label_loading = QLabel("Loading...")
self.label_loading.setAlignment(
self.label_loading.alignment()
)
layout.addWidget(self.label_loading)
# A status label
self.label_status = QLabel("Initializing...")
layout.addWidget(self.label_status)
# A button to run another background task later
self.button_run = QPushButton("Run Task")
self.button_run.setEnabled(False)
self.button_run.clicked.connect(self.start_main_task)
layout.addWidget(self.button_run)
# Thread pool for background work
self.threadpool = QThreadPool()
# Show the window
self.show()
# Start the preparation task with loading GIF
self.start_preparing()
# -- Preparation task (runs at startup) --
def start_preparing(self):
"""Start the loading GIF and run preparation in the background."""
# Start the animated GIF
self.gif_loading = QMovie('loading.gif')
self.label_loading.setMovie(self.gif_loading)
self.gif_loading.start()
# Create and start the worker
worker = Worker(self.prepare_app)
worker.signals.result.connect(self.on_prepare_result)
worker.signals.finished.connect(self.on_prepare_finished)
self.threadpool.start(worker)
# Do NOT stop the GIF here — let it run until the worker finishes.
def prepare_app(self, progress_callback):
"""Simulate a slow preparation task."""
time.sleep(5) # Simulate work taking 5 seconds
return "Preparation complete!"
def on_prepare_result(self, result):
"""Handle the result from preparation."""
self.label_status.setText(result)
def on_prepare_finished(self):
"""Clean up after preparation is done."""
self.gif_loading.stop()
self.label_loading.setText("Ready!")
self.button_run.setEnabled(True)
# -- Main task (triggered by button) --
def start_main_task(self):
"""Start the loading GIF and run the main task in the background."""
self.button_run.setEnabled(False)
self.button_run.setText("Running...")
self.label_status.setText("Working...")
# Restart the loading GIF
self.label_loading.setMovie(self.gif_loading)
self.gif_loading.start()
worker = Worker(self.run_main_task)
worker.signals.result.connect(self.on_main_result)
worker.signals.finished.connect(self.on_main_finished)
self.threadpool.start(worker)
def run_main_task(self, progress_callback):
"""Simulate a long-running main task."""
time.sleep(8) # Simulate work taking 8 seconds
return "Main task finished!"
def on_main_result(self, result):
self.label_status.setText(result)
def on_main_finished(self):
self.gif_loading.stop()
self.label_loading.setText("Done!")
self.button_run.setEnabled(True)
self.button_run.setText("Run Task")
if __name__ == '__main__':
app = QApplication(sys.argv)
window = MainWindow()
app.exec()
When you run this, you'll see the animated GIF playing during the simulated 5-second startup task. Once it finishes, the GIF stops, the label updates to "Ready!", and the button becomes enabled. Clicking the button repeats the same pattern for a second task.
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!