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