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.
If you're not familiar with how QThreadPool works, see the complete guide to Multithreading in PyQt6 applications with QThreadPool.
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. This approach relies on signals and slots — Qt's mechanism for communication between objects — to trigger the cleanup at the right moment.
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.
The example uses a QVBoxLayout to arrange the widgets — if you want to learn more about organizing your UI, see the PyQt6 layouts tutorial. If you're new to PyQt6 and want to start from scratch, take a look at creating your first window with PyQt6.
PyQt/PySide Office Hours 1:1 with Martin Fitzpatrick
Save yourself time and frustration. Get one on one help with your projects. Bring issues, bugs and questions about usability to architecture and maintainability, and leave with solutions.