I need to call a legacy Bash program and display the results in a Qt window. The problem is the subprocess doesn't return each output line as it happens — it waits until the entire command is finished, then dumps everything to the window at once. If the command takes a long time, the user thinks the system is frozen. How can I get live, line-by-line output from a subprocess into my Qt application?
If you've ever launched a long-running external command from a PyQt6 application and watched your entire GUI freeze until it finishes, you've hit one of the most common pitfalls in Python GUI development: blocking the event loop.
The root cause is straightforward. When you call subprocess.run(), Python stops and waits for the process to complete before moving on. While it's waiting, Qt's event loop — the mechanism responsible for redrawing the window, responding to clicks, and processing signals — is completely stalled. No updates, no repaints, nothing. The GUI appears frozen.
There are two solid approaches to stream subprocess output in real time in PyQt6:
- Use
QProcess, which is Qt's built-in way to run external programs. It integrates directly with the event loop and emits signals as output becomes available. - Use a background
QThreadwith Python'ssubprocess.Popento read output line by line and send it back to the GUI via signals.
We'll walk through both approaches with complete, runnable examples.
- Why subprocess.run() Freezes Your PyQt6 GUI
- Approach 1: Streaming Subprocess Output with QProcess
- Approach 2: Streaming Subprocess Output Using QThread and subprocess.Popen
- Fixing Delayed Subprocess Output: Buffering Issues
- QProcess vs QThread: Which Approach Should You Use?
- Complete Example: Live Build Output Viewer in PyQt6
Why subprocess.run() Freezes Your PyQt6 GUI
Let's make this concrete. Here's a minimal example that demonstrates the problem:
import subprocess
import sys
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QPlainTextEdit,
QPushButton, QVBoxLayout, QWidget,
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Subprocess Demo - Blocking")
self.text_area = QPlainTextEdit()
self.text_area.setReadOnly(True)
self.button = QPushButton("Run Command")
self.button.clicked.connect(self.run_command)
layout = QVBoxLayout()
layout.addWidget(self.text_area)
layout.addWidget(self.button)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def run_command(self):
# This blocks the entire GUI until the command finishes!
result = subprocess.run(
["bash", "-c", "for i in 1 2 3 4 5; do echo Line $i; sleep 1; done"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
self.text_area.setPlainText(result.stdout.decode())
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Click the button, and the window becomes unresponsive for five seconds. Then all the output appears at once. The GUI didn't update during that time because subprocess.run() held control the entire time.
Now let's fix it.
Approach 1: Streaming Subprocess Output with QProcess
QProcess is Qt's own class for running external programs asynchronously. It starts the process and returns immediately, letting the event loop continue. As the external program produces output, QProcess emits the readyReadStandardOutput signal, which you can connect to a slot that reads and displays the new data.
This is the most "Qt-native" solution for displaying real-time subprocess output in PyQt6 and works well for many use cases. For a deeper dive into QProcess including handling stdin, managing multiple processes, and parsing output, see the complete QProcess tutorial.
import sys
from PyQt6.QtCore import QProcess
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QPlainTextEdit,
QPushButton, QVBoxLayout, QWidget,
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QProcess Live Output")
self.process = None
self.text_area = QPlainTextEdit()
self.text_area.setReadOnly(True)
self.button = QPushButton("Run Command")
self.button.clicked.connect(self.run_command)
layout = QVBoxLayout()
layout.addWidget(self.text_area)
layout.addWidget(self.button)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def run_command(self):
if self.process is not None:
return # Already running
self.text_area.clear()
self.button.setEnabled(False)
self.process = QProcess(self)
self.process.readyReadStandardOutput.connect(self.handle_stdout)
self.process.readyReadStandardError.connect(self.handle_stderr)
self.process.finished.connect(self.process_finished)
# QProcess takes the program and arguments separately.
# To run a bash command, pass "-c" and the command string as arguments.
self.process.start(
"bash",
["-c", "for i in 1 2 3 4 5; do echo \"Line $i\"; sleep 1; done"],
)
def handle_stdout(self):
data = self.process.readAllStandardOutput()
text = bytes(data).decode("utf-8")
self.text_area.appendPlainText(text.rstrip())
def handle_stderr(self):
data = self.process.readAllStandardError()
text = bytes(data).decode("utf-8")
self.text_area.appendPlainText(text.rstrip())
def process_finished(self):
self.text_area.appendPlainText("--- Process finished ---")
self.process = None
self.button.setEnabled(True)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
Run this, click the button, and you'll see each line appear one at a time, with the GUI remaining fully responsive throughout.
How QProcess Streams Output in Real Time
When you call self.process.start(), the external command begins running in the background. Qt's event loop keeps spinning, so your window stays responsive.
Each time the external process writes to stdout, QProcess emits readyReadStandardOutput. The connected slot (handle_stdout) reads the available data and appends it to the text area. The same pattern applies for stderr.
When the process exits, the finished signal fires, and we clean up.
Running Complex Bash Commands with QProcess
If your actual command involves sourcing setup files, changing directories, and running build tools — like in the original question — you can pass the entire sequence as a single string to bash -c:
command = (
"source /path/to/setup_file -r && "
"cd /path/to/parent_directory && "
"build_project_command"
)
self.process.start("bash", ["-c", command])
This works because bash -c accepts the whole pipeline as one argument.
Approach 2: Streaming Subprocess Output Using QThread and subprocess.Popen
Sometimes QProcess doesn't quite fit your needs. For example, you might need to do additional processing on each line of output before displaying it, or you might need to integrate with Python libraries that expect a file-like object. In these cases, running subprocess.Popen in a background QThread is a good alternative.
The idea: spin up a QThread that runs the subprocess, reads its output line by line, and emits a signal for each line. The main thread receives those signals and updates the GUI safely. If you're new to threading in PyQt6, our guide to multithreading with QThreadPool covers the fundamentals of running background tasks without freezing the GUI.
import subprocess
import sys
from PyQt6.QtCore import QThread, pyqtSignal
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QPlainTextEdit,
QPushButton, QVBoxLayout, QWidget,
)
class SubprocessWorker(QThread):
"""Runs a subprocess in a background thread and emits output line by line."""
output_line = pyqtSignal(str)
finished_signal = pyqtSignal(int) # exit code
def __init__(self, command):
super().__init__()
self.command = command
def run(self):
process = subprocess.Popen(
self.command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # Line-buffered
)
for line in process.stdout:
self.output_line.emit(line.rstrip())
process.wait()
self.finished_signal.emit(process.returncode)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("QThread + Subprocess Live Output")
self.worker = None
self.text_area = QPlainTextEdit()
self.text_area.setReadOnly(True)
self.button = QPushButton("Run Command")
self.button.clicked.connect(self.run_command)
layout = QVBoxLayout()
layout.addWidget(self.text_area)
layout.addWidget(self.button)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def run_command(self):
if self.worker is not None:
return
self.text_area.clear()
self.button.setEnabled(False)
self.worker = SubprocessWorker(
["bash", "-c", "for i in 1 2 3 4 5; do echo \"Line $i\"; sleep 1; done"]
)
self.worker.output_line.connect(self.on_output_line)
self.worker.finished_signal.connect(self.on_finished)
self.worker.start()
def on_output_line(self, text):
self.text_area.appendPlainText(text)
def on_finished(self, exit_code):
self.text_area.appendPlainText(f"--- Process finished (exit code {exit_code}) ---")
self.worker = None
self.button.setEnabled(True)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()
How QThread with subprocess.Popen Works
subprocess.Popen (unlike subprocess.run) starts the process and returns immediately, giving you a handle to interact with it. By iterating over process.stdout, you get each line as it's produced.
Because this iteration is blocking (it waits for the next line), we run it in a QThread so it doesn't block the GUI. Each time a line arrives, the worker emits output_line, which is safely delivered to the main thread via Qt's signal-slot mechanism.
Setting bufsize=1 and text=True enables line-buffered mode, which means output is available to read as soon as a newline character is written by the subprocess.
Fixing Delayed Subprocess Output: Buffering Issues
Even with both approaches working correctly on the Qt side, you might still see delayed output if the external program itself buffers its stdout. Many programs buffer output differently when they detect they're writing to a pipe (which is what happens with both QProcess and subprocess.Popen) versus writing to a terminal.
If your external program supports it, you can try:
- Setting the
PYTHONUNBUFFERED=1environment variable (for Python scripts). - Using
stdbuf -oLto force line-buffered output:stdbuf -oL your_command. - Using
scriptorunbuffer(from theexpectpackage) to trick the program into thinking it's connected to a terminal.
For example, with the QProcess approach:
self.process.start(
"bash",
["-c", "stdbuf -oL your_long_running_command"],
)
QProcess vs QThread: Which Approach Should You Use?
Use QProcess when you're running a simple external command and want a clean, Qt-integrated solution. It handles the event loop integration for you, supports signals for stdout, stderr, and process completion, and doesn't require managing threads.
Use a background QThread when you need more control over how you read the output — for example, if you want to parse each line, filter output, or interact with the subprocess's stdin in complex ways. The thread approach also makes it straightforward to use Python's subprocess module features that don't have direct equivalents in QProcess.
Both approaches keep the GUI responsive and deliver output in real time. Pick whichever fits your situation best.
Complete Example: Live Build Output Viewer in PyQt6
Here's a more polished example that combines the QProcess approach with a few usability improvements — a scrolling output view, a status indicator, and support for running a configurable command. This example uses layouts and basic widgets to build the interface:
import sys
from PyQt6.QtCore import QProcess
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
class BuildOutputViewer(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Live Build Output Viewer")
self.resize(700, 500)
self.process = None
# Command input
self.command_input = QLineEdit()
self.command_input.setPlaceholderText(
"Enter bash command, e.g.: for i in $(seq 1 10); do echo Building step $i; sleep 0.5; done"
)
self.command_input.setText(
"for i in $(seq 1 10); do echo \"Building step $i...\"; sleep 0.5; done && echo Done!"
)
# Output area
self.output_area = QPlainTextEdit()
self.output_area.setReadOnly(True)
self.output_area.setFont(QFont("Courier", 10))
self.output_area.setStyleSheet(
"QPlainTextEdit { background-color: #1e1e1e; color: #d4d4d4; }"
)
# Buttons and status
self.run_button = QPushButton("Run")
self.run_button.clicked.connect(self.start_process)
self.stop_button = QPushButton("Stop")
self.stop_button.clicked.connect(self.stop_process)
self.stop_button.setEnabled(False)
self.status_label = QLabel("Ready")
button_layout = QHBoxLayout()
button_layout.addWidget(self.run_button)
button_layout.addWidget(self.stop_button)
button_layout.addWidget(self.status_label)
button_layout.addStretch()
layout = QVBoxLayout()
layout.addWidget(self.command_input)
layout.addLayout(button_layout)
layout.addWidget(self.output_area)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def start_process(self):
command = self.command_input.text().strip()
if not command:
return
self.output_area.clear()
self.run_button.setEnabled(False)
self.stop_button.setEnabled(True)
self.status_label.setText("Running...")
self.process = QProcess(self)
self.process.readyReadStandardOutput.connect(self.handle_stdout)
self.process.readyReadStandardError.connect(self.handle_stderr)
self.process.finished.connect(self.process_finished)
self.process.start("bash", ["-c", command])
def stop_process(self):
if self.process is not None:
self.process.kill()
def handle_stdout(self):
data = self.process.readAllStandardOutput()
text = bytes(data).decode("utf-8")
self.output_area.appendPlainText(text.rstrip())
def handle_stderr(self):
data = self.process.readAllStandardError()
text = bytes(data).decode("utf-8")
self.output_area.appendPlainText(text.rstrip())
def process_finished(self, exit_code, exit_status):
status_text = "Finished" if exit_code == 0 else f"Exited with code {exit_code}"
self.status_label.setText(status_text)
self.run_button.setEnabled(True)
self.stop_button.setEnabled(False)
self.process = None
app = QApplication(sys.argv)
window = BuildOutputViewer()
window.show()
app.exec()
This gives you a terminal-styled output viewer where you can type in a command, run it, watch the output stream in line by line, and stop it if needed — all without the GUI ever locking up.
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.