I have a confession. For years, when a developer proudly showed me their Python app — gray square buttons, a Listbox straight out of 1998 — I would politely nod. I've stopped doing that.
Not because I turned mean. Because PyQt6 exists, and there's no excuse anymore.
This article is my attempt to convince you — yes, you, the one still typing import tkinter out of habit — that something radically better is sitting one pip install away. I'll walk you through side-by-side comparisons and real snippets from a project I've been building for months: WatchTower, a website defacement monitoring system written entirely in PyQt6.
Spoiler: by the end, you'll want to rewrite everything you ever shipped in Tkinter.
The Tkinter problem in three lines
Let's be honest. Tkinter ships with Python, it's free, it's documented, and it works. That's the whole pitch. The rest is masochism.
# Tkinter — a "dashboard" that hurts to look at
import tkinter as tk
from tkinter import ttk
root = tk.Tk()
root.title("My fancy tool")
root.geometry("400x300")
label = tk.Label(root, text="Status: OK", bg="grey", fg="white")
label.pack(pady=10)
button = tk.Button(root, text="Scan")
button.pack()
root.mainloop()
You get something gray, flat, screaming "1998" at the top of its lungs. Want to change the font? Fight with font=("Arial", 10). Want a dark theme? Good luck. Want a real-time chart? Cram matplotlib in there and pray.
The same thing in PyQt6
Now look at the equivalent in PyQt6:
# PyQt6 — clean, modern, stylable
import sys
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QLabel, QPushButton, QVBoxLayout, QWidget
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("My fancy tool")
self.resize(400, 300)
central = QWidget()
layout = QVBoxLayout(central)
self.label = QLabel("Status: OK")
self.button = QPushButton("Scan")
self.button.clicked.connect(self.on_scan)
layout.addWidget(self.label)
layout.addWidget(self.button)
self.setCentralWidget(central)
self.setStyleSheet("""
QMainWindow { background-color: #282c34; }
QLabel { color: #61afef; font-size: 14pt; font-weight: bold; }
QPushButton {
background-color: #61afef; color: #282c34;
border-radius: 6px; padding: 8px 16px; font-weight: bold;
}
QPushButton:hover { background-color: #56a4e0; }
""")
def on_scan(self):
self.label.setText("Scanning...")
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
A few extra lines. But the result? A dark window, a button with rounded corners and a hover effect, readable typography. And we didn't install a single external theme.
Why PyQt6 changes the game
1. Stylesheets (QSS) are basically CSS for your app
This is probably the most underrated feature in Qt. If you know CSS, you already know 80% of QSS. Here's a slice of the dark theme I use in WatchTower:
QWidget {
background-color: #282c34;
color: #abb2bf;
font-family: "Segoe UI", "Roboto", sans-serif;
font-size: 10pt;
}
QPushButton {
background-color: #3a3f4b;
border: 1px solid #4b5263;
border-radius: 4px;
padding: 6px 12px;
}
QPushButton:hover { background-color: #4b5263; }
QHeaderView::section {
background-color: #21252b;
color: #e6efff;
padding: 6px;
}
Load it from a .qss file, apply it via app.setStyleSheet(qss), and the whole app is themed in one shot. Light, dark, cyan, accessible — you swap a string. With Tkinter you styled widget by widget, by hand, while quietly weeping.
2. The signal/slot system is a superpower
In Tkinter, components talk to each other through callbacks and sad global variables. In Qt, every widget can emit signals, and anything else can subscribe.
In WatchTower I have clickable KPI cards that emit a card_clicked signal carrying the card's title ("ACTIVE ALERTS", "MONITORED SITES", etc.). The dashboard listens and filters the list accordingly:
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import QFrame
class KpiCard(QFrame):
card_clicked = pyqtSignal(str) # declares a signal that emits a string
def mousePressEvent(self, event):
self.card_clicked.emit(self.title)
super().mousePressEvent(event)
# On the dashboard side:
self.alerts_card.card_clicked.connect(self.filter_by_category)
Decoupled. Testable. Scalable. The widget doesn't know who's listening, the listener doesn't know where the signal came from. Real event-driven design — not three layers of command=lambda: ... stacked on top of each other.
3. Threading that doesn't freeze your UI
The worst moment in any Tkinter app is when you fire off a long-running operation and the window goes white for 30 seconds. You know it. We all know it.
PyQt gives you QThread and QRunnable with QThreadPool, and signals work across threads. In WatchTower, my async scanner runs in a dedicated worker and pipes results back to the UI through signals:
from PyQt6.QtCore import QThread, pyqtSignal
class ScanWorker(QThread):
progress = pyqtSignal(int, str) # %, message
site_done = pyqtSignal(dict) # structured result
finished = pyqtSignal()
def __init__(self, sites):
super().__init__()
self.sites = sites
def run(self):
for i, site in enumerate(self.sites):
result = self.scan_one(site) # blocking — that's fine in here
self.progress.emit(int(100 * (i+1) / len(self.sites)), site["url"])
self.site_done.emit(result)
self.finished.emit()
# In the main window:
self.worker = ScanWorker(my_sites)
self.worker.progress.connect(self.statusBar().showMessage)
self.worker.site_done.connect(self.dashboard.add_result)
self.worker.start()
The UI stays responsive. You can scroll, click elsewhere, open a dialog. Tkinter doesn't do this natively, and the threading + after() workarounds are fragile.
4. The widgets you actually want in 2026
Tkinter gives you a Listbox. Qt gives you QListView, QTableView, QTreeView with a proper model/view system, sorting, filtering, inline editing, drag & drop, and virtualization for millions of rows. Drop a QSortFilterProxyModel on top and you get text search for free.
For real-time charts, you have QtCharts (official) or pyqtgraph (extremely fast, perfect for monitoring dashboards). In WatchTower I plot live alert counts and CPU usage with zero lag.
And icons? One line with qtawesome:
import qtawesome as qta
button.setIcon(qta.icon("fa5s.shield-alt", color="#61afef"))
Thousands of Font Awesome, Material Design, and Elusive icons — recolorable, resizable, vectorial.
WatchTower, the project that converted me
I built WatchTower, a website defacement monitoring system, because I needed a real-time dashboard surveilling dozens of sites. Here's what PyQt6 let me do painlessly:
- A modern dashboard with clickable KPI cards, live stats, and a multi-site grid (1, 2, or 4 sites in parallel)
-
Embedded WebViews (
QWebEngineView) showing the actual rendered pages — practically impossible to do cleanly in Tkinter - A theming system: dark, light, cyan — hot-swappable without restarting
- Async workers that scan in parallel without freezing the interface
- Incident timelines, multi-tab config dialogs, system tray, native notifications, PDF report generation…
Every UI brick is a reusable widget that emits its own signals. When I add a new data source, I don't rewrite half the interface — I wire up a signal and move on.
The same project in Tkinter? Either it wouldn't exist, or it'd be 10,000 lines of spaghetti.
"But Tkinter is in the standard library!"
Yes. So is urllib, and yet everyone uses requests. Performance and ergonomics matter more than skipping a pip install.
For reference:
pip install PyQt6 qtawesome pyqtgraph
That's all you need to ship apps that look like real tools, not freshman CS projects.
PyQt6 vs PyQt5?
I started on PyQt5 and moved to PyQt6 a while back. The main differences:
-
Namespaced enums:
Qt.AlignCenterbecomesQt.AlignmentFlag.AlignCenter. More verbose, but much clearer. -
exec_()is nowexec(): Python 3 freed the keyword. - Cleaner, better typed, and that's the version Qt keeps investing in.
If you're starting today, go straight to PyQt6 (or PySide6 if you want the LGPL license).




Top comments (0)