How to run parallel process along with QProcess thread

Running background processes and file watching simultaneously in PyQt6 without blocking the GUI
Heads up! You've already completed this tutorial.

A common pattern when building GUI applications is to kick off a long-running backend process (using QProcess) and then wait for that process to produce some output — often a file on disk — that you want to load back into your interface. The temptation is to write a loop that checks for the file and blocks until it appears. But doing that in the main thread freezes everything: your runtime logs stop updating, your buttons become unresponsive, and the whole application appears to hang.

In this tutorial, we'll look at why this happens and walk through two practical approaches to fix it: polling with QTimer and reacting to filesystem changes with QFileSystemWatcher.

Why does the GUI freeze?

PyQt6 runs an event loop in the main thread. This event loop is responsible for processing every interaction in your application — button clicks, repaints, signal delivery, and log output from QProcess. When you write something like this:

python
import os
import time

def search_for_json_file(self):
    while not os.path.exists("/path/to/output.json"):
        time.sleep(0.5)
    self.load_file("/path/to/output.json")

…that while loop takes over the main thread. The event loop can't process anything else until the loop finishes. Instead of sitting in a loop waiting, you need to check periodically (or get notified) and return control to the event loop between checks.

Approach 1: Polling with QTimer

The simplest way to check for a file without blocking is to use a QTimer. A timer fires a signal at a regular interval, and between firings the event loop is free to do its normal work — including delivering your QProcess log output.

Here's a complete working example. This starts a simulated backend process (a small script that writes a JSON file after a few seconds) and polls for that file using a QTimer:

python
import sys
import os
import json
import tempfile

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QVBoxLayout, QWidget,
    QPushButton, QPlainTextEdit, QLabel
)
from PyQt6.QtCore import QProcess, QTimer


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QProcess + File Polling")

        layout = QVBoxLayout()

        self.start_button = QPushButton("Start Process")
        self.start_button.clicked.connect(self.start_all)
        layout.addWidget(self.start_button)

        self.log_output = QPlainTextEdit()
        self.log_output.setReadOnly(True)
        layout.addWidget(self.log_output)

        self.status_label = QLabel("Status: idle")
        layout.addWidget(self.status_label)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        self.p = None
        self.poll_timer = None

        # The file we're waiting for — using a temp directory
        # so the example works anywhere.
        self.output_file = os.path.join(
            tempfile.gettempdir(), "qprocess_output.json"
        )
        # Clean up from any previous run.
        if os.path.exists(self.output_file):
            os.remove(self.output_file)

    def start_all(self):
        """Called when the button is clicked."""
        self.start_button.setEnabled(False)
        self.log_output.clear()
        self.status_label.setText("Status: running...")

        # Clean up any leftover file.
        if os.path.exists(self.output_file):
            os.remove(self.output_file)

        self.start_process()
        self.start_polling()

    def start_process(self):
        """Launch the backend script via QProcess."""
        self.p = QProcess()
        self.p.readyReadStandardOutput.connect(self.handle_stdout)
        self.p.readyReadStandardError.connect(self.handle_stderr)
        self.p.finished.connect(self.process_finished)

        # We'll run a small inline Python script that prints log lines
        # and then writes a JSON file.
        script = f"""
import time, json

for i in range(1, 6):
    print(f"Processing step {{i}} of 5...")
    time.sleep(1)

output = {{"result": "success", "items": [1, 2, 3]}}
with open(r"{self.output_file}", "w") as f:
    json.dump(output, f)

print("Done! File written.")
"""
        self.p.start("python", ["-u", "-c", script])

    def handle_stdout(self):
        data = self.p.readAllStandardOutput().data().decode()
        self.log_output.appendPlainText(data.strip())

    def handle_stderr(self):
        data = self.p.readAllStandardError().data().decode()
        self.log_output.appendPlainText(f"[stderr] {data.strip()}")

    def process_finished(self):
        self.log_output.appendPlainText("--- Process finished ---")

    def start_polling(self):
        """Start a QTimer that checks for the output file every 500ms."""
        self.poll_timer = QTimer()
        self.poll_timer.setInterval(500)  # milliseconds
        self.poll_timer.timeout.connect(self.check_for_file)
        self.poll_timer.start()

    def check_for_file(self):
        """Called every 500ms by the timer. Does NOT block."""
        if os.path.exists(self.output_file):
            self.poll_timer.stop()
            self.load_file()

    def load_file(self):
        """Load the JSON file and display the results."""
        with open(self.output_file, "r") as f:
            data = json.load(f)

        self.status_label.setText(f"Status: loaded — {data}")
        self.log_output.appendPlainText(f"Loaded JSON: {data}")
        self.start_button.setEnabled(True)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

When you run this and click Start Process, you'll see the log lines from the backend script appearing one by one in the text area. Meanwhile, the QTimer quietly checks for the output file every half second. Once the file appears, it loads and displays the contents — all without any freezing.

Notice the -u flag passed to the Python process. This puts Python's stdout into unbuffered mode, so print() output is delivered to QProcess immediately rather than being held in a buffer. Without this, you might still see log lines arrive in batches.

How the timer approach works

The QTimer.timeout signal fires every 500 milliseconds. Each time, check_for_file() runs, does a quick os.path.exists() check, and returns immediately. Between those checks, the event loop is completely free. Your QProcess signals (readyReadStandardOutput, etc.) get processed normally, so logs appear in real time.

When the file is found, the timer stops itself and calls load_file(). Clean and simple.

Approach 2: Using QFileSystemWatcher

If you'd rather be notified when a file appears instead of polling for it, Qt provides QFileSystemWatcher. This class monitors files and directories for changes and emits signals when something happens.

Since the file doesn't exist yet when we start watching, we watch the directory where the file will appear. When the directory's contents change, we check whether our file has arrived.

python
import sys
import os
import json
import tempfile

from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QVBoxLayout, QWidget,
    QPushButton, QPlainTextEdit, QLabel
)
from PyQt6.QtCore import QProcess, QFileSystemWatcher


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QProcess + QFileSystemWatcher")

        layout = QVBoxLayout()

        self.start_button = QPushButton("Start Process")
        self.start_button.clicked.connect(self.start_all)
        layout.addWidget(self.start_button)

        self.log_output = QPlainTextEdit()
        self.log_output.setReadOnly(True)
        layout.addWidget(self.log_output)

        self.status_label = QLabel("Status: idle")
        layout.addWidget(self.status_label)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        self.p = None

        self.output_dir = tempfile.gettempdir()
        self.output_file = os.path.join(self.output_dir, "qprocess_output.json")

        # Clean up from any previous run.
        if os.path.exists(self.output_file):
            os.remove(self.output_file)

        # Set up the filesystem watcher.
        self.watcher = QFileSystemWatcher()
        self.watcher.addPath(self.output_dir)
        self.watcher.directoryChanged.connect(self.on_directory_changed)

    def start_all(self):
        self.start_button.setEnabled(False)
        self.log_output.clear()
        self.status_label.setText("Status: running...")

        if os.path.exists(self.output_file):
            os.remove(self.output_file)

        self.start_process()

    def start_process(self):
        self.p = QProcess()
        self.p.readyReadStandardOutput.connect(self.handle_stdout)
        self.p.readyReadStandardError.connect(self.handle_stderr)
        self.p.finished.connect(self.process_finished)

        script = f"""
import time, json

for i in range(1, 6):
    print(f"Processing step {{i}} of 5...")
    time.sleep(1)

output = {{"result": "success", "items": [1, 2, 3]}}
with open(r"{self.output_file}", "w") as f:
    json.dump(output, f)

print("Done! File written.")
"""
        self.p.start("python", ["-u", "-c", script])

    def handle_stdout(self):
        data = self.p.readAllStandardOutput().data().decode()
        self.log_output.appendPlainText(data.strip())

    def handle_stderr(self):
        data = self.p.readAllStandardError().data().decode()
        self.log_output.appendPlainText(f"[stderr] {data.strip()}")

    def process_finished(self):
        self.log_output.appendPlainText("--- Process finished ---")

    def on_directory_changed(self, path):
        """Triggered when something changes in the watched directory."""
        if os.path.exists(self.output_file):
            self.load_file()

    def load_file(self):
        with open(self.output_file, "r") as f:
            data = json.load(f)

        self.status_label.setText(f"Status: loaded — {data}")
        self.log_output.appendPlainText(f"Loaded JSON: {data}")
        self.start_button.setEnabled(True)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

This version doesn't use a timer at all. The QFileSystemWatcher listens for changes to the temp directory, and when the backend script writes the JSON file, the directoryChanged signal fires. Inside on_directory_changed, we check whether the specific file we're interested in has appeared, and if so, we load it.

A note about temp directories

Watching a busy directory like the system temp folder means on_directory_changed may fire for unrelated changes. That's fine — the os.path.exists() check inside the handler keeps things safe. If your output file lands in a more predictable location, the watcher will fire less frequently.

Which approach should you use?

For most situations — especially when your backend process takes several seconds and you don't need sub-second response times — the QTimer approach is perfectly fine and easy to reason about. If you want to react the moment a file appears, QFileSystemWatcher is the better choice.

The most important thing is that neither approach blocks the event loop. Your QProcess logs will flow in real time, your buttons stay responsive, and your file gets loaded as soon as it's available.

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

Packaging Python Applications with PyInstaller by Martin Fitzpatrick

This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.

More info Get the book

Martin Fitzpatrick

How to run parallel process along with QProcess thread 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.