If you're building Python GUI applications with Qt, you've probably come across both PyQt5 and PySide2. These two libraries provide Python bindings to the same underlying Qt framework, and for the most part they are remarkably similar. But there are a handful of differences that can trip you up if you're not aware of them.
In this article, we'll look at what those differences are, and how you can write code that works with either library — whether you're building an application or distributing a reusable library.
Why two libraries?
PyQt5 and PySide2 both wrap the same C++ Qt library, so the widgets, layouts, signals, and other Qt concepts work the same way in both. The split comes down to licensing and history. PyQt5 is developed by Riverbank Computing and is available under the GPL or a commercial license. PySide2 is the official Qt for Python project, available under the LGPL.
For most developers, the practical differences between the two are small. The APIs are nearly identical. But "nearly" is the operative word — there are a few spots where they diverge.
The main differences
Let's walk through the areas where PyQt5 and PySide2 differ, and how to handle each one.
Signals and Slots
This is the most visible difference. In PySide2, custom signals and slots are defined using Signal and Slot from PySide2.QtCore. In PyQt5, the equivalents are called pyqtSignal and pyqtSlot.
PySide2:
from PySide2.QtCore import Signal, Slot
PyQt5:
from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
By aliasing pyqtSignal to Signal and pyqtSlot to Slot at import time, you can use the same names throughout your code regardless of which library you're using.
uic and loading .ui files
If you use Qt Designer to build your interfaces, you'll work with .ui files. Both libraries can load these, but the mechanism is slightly different.
PyQt5 provides uic.loadUi() which can load a .ui file directly onto a widget:
from PyQt5 import uic
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
uic.loadUi("mainwindow.ui", self)
PySide2 uses QUiLoader, which creates and returns a new widget rather than loading onto an existing one:
from PySide2.QtUiTools import QUiLoader
loader = QUiLoader()
window = loader.load("mainwindow.ui")
window.show()
This difference is harder to paper over with a simple alias. If you need to support both, the cleanest approach is to compile your .ui files to Python code ahead of time using pyuic5 (PyQt5) or pyside2-uic (PySide2), and import the resulting module. The generated code will be specific to the library you compiled with, but your own code won't need to call any loader directly.
QApplication.exec() vs QApplication.exec_()
In Python 2, exec was a reserved keyword, so both libraries used exec_() as the method name. PySide2 now supports both exec() and exec_(). PyQt5 also supports both in recent versions. If you're using Python 3 (and you should be), prefer exec_() for maximum compatibility across library versions.
app.exec_()
Other minor differences
There are a few other small differences:
- Property types: PyQt5 uses
pyqtProperty, PySide2 usesProperty. qApp: Both provide a global application instance, but import locations can differ.- Enums: In some cases, the enum access patterns have minor variations, though this is more of a Qt version issue than a binding issue.
For most application code, signals/slots and .ui file loading are the only differences you'll encounter regularly.
Creating a compatibility layer
If you want your code to work with both libraries, a good approach is to create a small compatibility module — often called qt.py — that handles the import differences in one place. The rest of your code then imports from this module and never needs to worry about which binding is installed.
Here's a straightforward way to do this:
# qt.py
import os
import sys
# Determine which binding to use.
# Check for an environment variable first, then try importing.
QT_API = os.environ.get("QT_API", "").lower()
if QT_API == "pyqt5":
from PyQt5 import QtGui, QtWidgets, QtCore
from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
elif QT_API == "pyside2":
from PySide2 import QtGui, QtWidgets, QtCore
from PySide2.QtCore import Signal, Slot
else:
# No preference specified — try PyQt5 first, fall back to PySide2.
try:
from PyQt5 import QtGui, QtWidgets, QtCore
from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
except ImportError:
from PySide2 import QtGui, QtWidgets, QtCore
from PySide2.QtCore import Signal, Slot
Then in the rest of your project, you import from qt instead of directly from PyQt5 or PySide2:
from qt import QtWidgets, Signal, Slot
Why the environment variable?
You might be wondering why we check QT_API instead of just using a try/except import. For applications, a simple try/except works well:
try:
from PyQt5 import QtGui, QtWidgets, QtCore
from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
except ImportError:
from PySide2 import QtGui, QtWidgets, QtCore
from PySide2.QtCore import Signal, Slot
This is the "Easier to Ask Forgiveness than Permission" (EAFP) approach, and it's perfectly Pythonic. If PyQt5 is installed, it gets used. If not, PySide2 is tried instead.
But consider this scenario: you're writing a library that other developers will import into their applications. A developer is building their app with PySide2, but they also happen to have PyQt5 installed in the same environment (maybe for another project). With the try/except approach, your library would silently import PyQt5 — and now the application is mixing two different Qt bindings in the same process, which leads to crashes and deeply confusing bugs.
The environment variable approach lets the application developer declare which binding should be used, and your library respects that choice. Libraries like qtpy and Qt.py use this same pattern for exactly this reason.
For your own applications (where you control the environment), the try/except approach works perfectly. For libraries, the environment variable adds a layer of safety.
Using qtpy as an alternative
If you'd rather not maintain your own compatibility module, the qtpy package does all of this for you. It provides a unified API that wraps both PyQt5 and PySide2 (and PyQt6 and PySide6 too):
pip install qtpy
from qtpy import QtWidgets
from qtpy.QtCore import Signal, Slot
qtpy checks the QT_API environment variable and automatically resolves differences between the bindings. It's used by the Spyder IDE and several other large projects, so it's well-tested.
A complete example
Here's a small application that uses our qt.py compatibility layer, so it will run on either PyQt5 or PySide2 — whichever you have installed:
qt.py:
import os
QT_API = os.environ.get("QT_API", "").lower()
if QT_API == "pyqt5":
from PyQt5 import QtGui, QtWidgets, QtCore
from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
elif QT_API == "pyside2":
from PySide2 import QtGui, QtWidgets, QtCore
from PySide2.QtCore import Signal, Slot
else:
try:
from PyQt5 import QtGui, QtWidgets, QtCore
from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
except ImportError:
from PySide2 import QtGui, QtWidgets, QtCore
from PySide2.QtCore import Signal, Slot
app.py:
import sys
from qt import QtWidgets, Signal, Slot, QtCore
class MainWindow(QtWidgets.QMainWindow):
message_sent = Signal(str)
def __init__(self):
super().__init__()
self.setWindowTitle("Cross-compatible Qt App")
# Create widgets.
self.input = QtWidgets.QLineEdit()
self.input.setPlaceholderText("Type a message and press Enter...")
self.output = QtWidgets.QLabel("No messages yet.")
self.output.setAlignment(QtCore.Qt.AlignCenter)
# Layout.
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.input)
layout.addWidget(self.output)
container = QtWidgets.QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
# Connections.
self.input.returnPressed.connect(self.send_message)
self.message_sent.connect(self.display_message)
@Slot()
def send_message(self):
text = self.input.text()
if text:
self.message_sent.emit(text)
self.input.clear()
@Slot(str)
def display_message(self, message):
self.output.setText(f"You said: {message}")
def main():
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
Place both files in the same directory and run python app.py. The application will work with whichever Qt binding you have installed. If both are available, it will default to PyQt5 — or you can force a specific one by setting the environment variable:
QT_API=pyside2 python app.py
Summary
PyQt5 and PySide2 are much more alike than they are different. The main points to remember are:
- Signals/Slots are named differently (
pyqtSignal/pyqtSlotvsSignal/Slot), but can be easily aliased. .uifile loading works differently between the two libraries.exec_()is the safe choice for compatibility.- A small compatibility module (
qt.py) can centralize these differences so the rest of your code stays clean. - For applications, a try/except import works well. For libraries, use an environment variable to let the consuming application choose.
- The
qtpypackage is a ready-made solution if you don't want to maintain your own shim.
With a little bit of setup, you can write Qt applications and libraries that work across both bindings without duplicating any code.
Create GUI Applications with Python & Qt6 by Martin Fitzpatrick
(PySide6 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!