How to display a loading animated gif while a code is executing in backend of my Python Qt6 UI?

Use QThreadPool and QMovie to show a loading animation without freezing your PyQt6 interface
Heads up! You've already completed this tutorial.

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.

python
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:

python
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.

python
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.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

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!

More info Get the book

Martin Fitzpatrick

How to display a loading animated gif while a code is executing in backend of my Python Qt6 UI? was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.