# QtPy, the layer that lets one Python codebase sit on PyQt5, PySide2, PyQt6 or PySide6

> QtPy is an abstraction layer over the four Qt bindings for Python. You import from qtpy instead of from a vendor package, the layer picks the binding that is installed, normalises the module layout to the Qt5 shape, and aliases the names that differ between PyQt and PySide. It also ships a command line tool that tells Mypy and Pyright which binding is in play so type checking matches runtime behaviour.

**spyder-ide/qtpy** — Provides an uniform layer to support PyQt5, PySide2, PyQt6, PySide6 with a single codebase

- Repository: https://github.com/spyder-ide/qtpy
- Stars: 1,106 · Forks: 166
- Language: Python
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/spyder-ide-qtpy

## The problem: four bindings, four sets of names

QtPy provides a uniform interface to support PyQt5, PySide2, PyQt6 and PySide6 through a single codebase. It abstracts the differences between bindings and versions, allowing software to remain portable and easier to maintain. The concrete pain is that the two binding families do not agree on names, so the same widget is one class in one and something else in the other, and signal and slot decorators are spelled differently again. The documented way to use it is to import from qtpy instead of PySide or PyQt, and the example is the shortest possible application: import the core and widgets modules, create a QApplication, create a QWidget, show it and run the event loop. The abstract features list spells out the promise: multiple bindings without conditional imports or logic branching, automatic detection and loading of whichever binding is installed or already imported, module structure normalised to the Qt5 layout, and API incompatibilities handled internally so that porting between Qt5 and Qt6 is incremental rather than a full scale rewrite.

```python
from qtpy import QtCore, QtWidgets

app = QtWidgets.QApplication()
widget = QtWidgets.QWidget()
widget.show()
app.exec()
```

## The binding is detected, and PyQt5 wins by default

The installation requirements are stated without ambiguity: you need one of the supported packages, PyQt5, PyQt6, PySide2 or PySide6, because QtPy does not install any binding by itself. If multiple of these libraries are found, PyQt5 is used by default. That default is worth knowing, because a machine that has PyQt5 lying around from another project will silently get PyQt5 rather than the PySide6 you expected. To set a specific binding you set the QT_API environment variable, and the accepted values are pyside6, pyside2, pyqt6 and pyqt5. The example sets the variable before the import, which is the ordering that matters, since the layer resolves the binding at import time. The detection is also aware of what has already been imported, so an application that imported PySide6 for another reason keeps that binding.

```python
import os
os.environ['QT_API'] = 'pyqt6'
from qtpy import QtCore, QtGui, QtWidgets
print(QtWidgets.QWidget)
```

## The aliases that remove the name guessing

The module aliases follow the Qt5 layout, and they cover the differences that bite most often. The PyQt spellings of the signal, slot and property decorators are aliased to the plain Qt names, so a class written against qtpy uses the same decorator names whichever binding is underneath. For PyQt6, unscoped enum access was added by promoting the enums of the QtCore, QtGui, QtTest and QtWidgets modules, which is what lets the same enum code run on PyQt5 where enums are scoped and PyQt6 where they are not. Compatibility is also added between the QtGui and QtOpenGL modules for the QOpenGL classes, so code that reached into the OpenGL module does not have to be rewritten when the layout changed. The example shows the payoff in one class: a signal declared with the plain name on the core module.

```python
from qtpy import QtCore, QtWidgets

class Widget(QtWidgets.QWidget):
    value_changed = QtCore.Signal(int)
```

## Constants and the compat module

A layer like this also has to expose the version information, and it does so through a set of module constants. There is the current Qt version available as both QtCore.__version__ and qtpy.QT_VERSION, the binding version for PySide as qtpy.PYSIDE_VERSION which is None if PySide is not in use, the equivalent qtpy.PYQT_VERSION for PyQt, and the currently selected binding as qtpy.API_NAME. Then there is a set of booleans for the specific combinations, PYSIDE2, PYSIDE6, PYQT5, PYQT6, and the Qt major version flags QT5 and QT6, so your own code can branch on the same facts the layer uses. Separately, the qtpy.compat module provides wrappers for QFileDialog static methods and for the SIP and Shiboken functions, mapping the four QFileDialog static calls to lowercased names and mapping sip.isdeleted or shiboken.isValid onto a single isalive call, which is the kind of small divergence that would otherwise leak into every project that supports both bindings.

```
qtpy.compat.getexistingdirectory
qtpy.compat.getopenfilename
qtpy.compat.getopenfilenames
qtpy.compat.getsavefilename
qtpy.compat.isalive
```

## The part most people miss: telling the type checker

Type checkers have no knowledge of installed packages, so these tools require additional configuration, and that is why QtPy ships a command line interface. The mypy-args command generates command line arguments for Mypy that enable it to process the QtPy source files with the same API as QtPy itself would have selected. In an environment where PyQt5 is installed and selected, which is also the default fallback when no binding is found, the output sets PyQt5 as always true and the other three always false, and that string can be injected into the Mypy invocation. For Pyright the approach is different, since it needs a configuration file rather than runtime arguments, so you create a pyrightconfig.json or a pyright section in pyproject.toml, and the pyright-config command prints the necessary configuration. If you have neither, the documentation says you should create them. This is a real cost of the abstraction and the project is upfront about it rather than leaving you to discover it.

```bash
mypy --package mypackage $(qtpy mypy-args)
```

## The package, and where it stands

The package is small in the way an abstraction should be. The build uses setuptools with a minimum of 42, the project requires Python 3.9 or newer, and the only runtime dependency is packaging, which is what it uses to compare version numbers. The console script is qtpy, which is the entry point for the mypy-args and pyright-config commands. The version is dynamic, read from the qtpy.__version__ attribute, and the classifiers mark it as Production/Stable, with Python 3.9 through 3.13 listed and environments covering macOS, Windows and X11 applications. The licence is MIT with the file at the root, and the copyright line reads the Spyder Development Team from 2009, so this is a project with a long history rather than a recent one. The recent releases are v2.4.3 from 2025-02-11, v2.4.2 from 2024-11-04 and v2.4.1 from 2023-10-23, and the last push to the repository was on 2026-04-12.

```toml
[project]
name = "QtPy"
requires-python = ">=3.9"
dependencies = ["packaging"]
```

## Where it fits, and what to check before you adopt it

The honest framing is that QtPy is insurance against a decision you have not made yet, and insurance has a premium. If you only support one binding and one Qt version, the layer is an extra import and an extra dependency for no benefit, and the type checker configuration becomes work with no payoff. If you publish a library, or you are migrating from PyQt5 to PySide6 or from Qt5 to Qt6, the layer earns its keep quickly, because the alternative is a set of conditional imports scattered through your code. Three things to check. First, the feature coverage, since the layer normalises names and versions rather than implementing what a binding lacks, so a PySide6-only feature still is not available on PyQt5. Second, the precedence, because PyQt5 by default means an environment with several bindings behaves in a way you may not expect. Third, the type checker setup, because a project that runs mypy or pyright in CI needs the generated arguments wired in, or the check will fail in a way that looks like a bug in your code rather than in the configuration.

```
If multiple of these libraries are found, PyQt5 is used by default.
```

## Conclusion

Adopt QtPy if you want your library or application to run on more than one Qt binding, or if you plan to move from Qt5 to Qt6 without rewriting the import lines, and skip it if you only ever use one binding, because then the abstraction buys you nothing and costs you a layer. Verify three things before you adopt it: that the Qt API features you use exist in every binding you intend to support, since the layer normalises names and versions rather than backfilling missing functionality; that the binding precedence is what you expect, since PyQt5 is the default when several are installed; and that you set the type checker configuration, because a checker has no knowledge of what is installed and will otherwise report errors for a binding you are not using.

## FAQ

### What does QtPy do exactly?

It provides a uniform interface to support PyQt5, PySide2, PyQt6 and PySide6 through a single codebase, abstracting the differences between bindings and versions so that software stays portable and easier to maintain. You import from qtpy rather than from a vendor package.

### How do I choose which Qt binding QtPy uses?

You set the QT_API environment variable to pyside6, pyside2, pyqt6 or pyqt5 before importing qtpy. If multiple bindings are installed and you do not set it, PyQt5 is used by default, and QtPy never installs a binding itself.

### How do I use QtPy with Mypy and Pyright?

Type checkers have no knowledge of installed packages, so they need extra configuration. The `qtpy mypy-args` command prints Mypy command line arguments matching the selected binding, and `qtpy pyright-config` prints the configuration you need for a pyrightconfig.json file or a pyright section in pyproject.toml.

### Does QtPy install a Qt binding for me?

No. The installation requires one of the supported packages, PyQt5, PyQt6, PySide2 or PySide6, since QtPy does not install any binding by itself. It is installed with pip install qtpy or conda install qtpy.

## Sources

- [Issues](https://github.com/spyder-ide/qtpy/issues)
- [License: MIT](https://github.com/spyder-ide/qtpy/blob/master/LICENSE)
- [README](https://github.com/spyder-ide/qtpy/blob/master/README.md)
- [Releases](https://github.com/spyder-ide/qtpy/releases)
- [spyder-ide/qtpy on GitHub](https://github.com/spyder-ide/qtpy)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/spyder-ide-qtpy
