I'm developing a program with many custom widgets. Each widget has a crosshair that follows the mouse, and when I move the mouse in one widget, all the others need to update too. I have
self.update()at the end of mypaintEventfunction. Everything works, but CPU usage is very high. How can I optimize painting in my custom widgets?
If you've ever built a custom widget in PyQt6 using paintEvent and noticed your CPU fan spinning up, you're likely running into one of the most common painting performance mistakes: accidentally creating an infinite paint loop. Let's walk through why this happens, how to fix it, and how to structure your painting code so your custom widgets stay responsive and efficient.
How paintEvent and update() work together
In PyQt6, whenever a widget needs to be redrawn, Qt calls its paintEvent() method. You don't call paintEvent() directly — instead, you call self.update(), which schedules a repaint. Qt then calls paintEvent() for you at the appropriate time.
This distinction matters. If you put self.update() inside paintEvent(), you're telling Qt to schedule another repaint every time it finishes painting. That creates an effective infinite loop:
paintEvent()runs → callsself.update()update()schedules a new paint →paintEvent()runs again- Repeat forever
Qt is smart enough to coalesce multiple update() calls, so your application won't completely freeze. But it will consume as much CPU as it can, constantly repainting even when nothing has changed.
The fix is straightforward: never call self.update() inside paintEvent().
Instead, call self.update() from wherever the change happens — typically in a mouse event handler, a signal slot, or a timer callback.
A minimal example: the wrong way
Here's a simple custom widget that draws a crosshair at the mouse position. This version has the performance bug:
# Don't do this! Infinite repaint loop.
from PyQt6.QtWidgets import QApplication, QWidget
from PyQt6.QtGui import QPainter, QPen
from PyQt6.QtCore import Qt
import sys
class CrosshairWidget(QWidget):
def __init__(self):
super().__init__()
self.mouse_x = 0
self.mouse_y = 0
self.setMouseTracking(True)
def mouseMoveEvent(self, event):
self.mouse_x = int(event.position().x())
self.mouse_y = int(event.position().y())
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.fillRect(self.rect(), Qt.GlobalColor.white)
pen = QPen(Qt.GlobalColor.red, 1)
painter.setPen(pen)
painter.drawLine(self.mouse_x, 0, self.mouse_x, self.height())
painter.drawLine(0, self.mouse_y, self.width(), self.mouse_y)
painter.end()
# BUG: This causes continuous repainting!
self.update()
app = QApplication(sys.argv)
w = CrosshairWidget()
w.setFixedSize(400, 300)
w.show()
sys.exit(app.exec())
The self.update() at the end of paintEvent() means the widget repaints endlessly, even when the mouse isn't moving. Remove that line and the widget only repaints when the mouse actually moves, because mouseMoveEvent already calls self.update().
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.
The correct approach
Here's the fixed version. The only place self.update() is called is in mouseMoveEvent, where the actual change happens:
from PyQt6.QtWidgets import QApplication, QWidget
from PyQt6.QtGui import QPainter, QPen
from PyQt6.QtCore import Qt
import sys
class CrosshairWidget(QWidget):
def __init__(self):
super().__init__()
self.mouse_x = 0
self.mouse_y = 0
self.setMouseTracking(True)
def mouseMoveEvent(self, event):
self.mouse_x = int(event.position().x())
self.mouse_y = int(event.position().y())
self.update() # Schedule a repaint — this is the right place.
def paintEvent(self, event):
painter = QPainter(self)
painter.fillRect(self.rect(), Qt.GlobalColor.white)
pen = QPen(Qt.GlobalColor.red, 1)
painter.setPen(pen)
painter.drawLine(self.mouse_x, 0, self.mouse_x, self.height())
painter.drawLine(0, self.mouse_y, self.width(), self.mouse_y)
painter.end()
# No self.update() here!
app = QApplication(sys.argv)
w = CrosshairWidget()
w.setFixedSize(400, 300)
w.show()
sys.exit(app.exec())
This version only repaints when necessary, keeping CPU usage low.
Updating multiple widgets from one mouse event
In the original question, moving the mouse over one widget needs to update crosshairs on all the other widgets too. The way to do this is to emit a signal from the widget where the mouse is moving, and connect that signal to update the other widgets.
Here's a complete example with multiple widgets that all show a synchronized crosshair:
from PyQt6.QtWidgets import QApplication, QWidget, QHBoxLayout
from PyQt6.QtGui import QPainter, QPen
from PyQt6.QtCore import Qt, pyqtSignal
import sys
class CrosshairWidget(QWidget):
# Signal emitted when the mouse moves, carrying x and y as fractions
# of the widget size (0.0 to 1.0). Using fractions means widgets of
# different sizes can share the same position.
crosshair_moved = pyqtSignal(float, float)
def __init__(self):
super().__init__()
self.cross_x = 0.5 # Fractional position (0.0 - 1.0)
self.cross_y = 0.5
self.setMouseTracking(True)
self.setMinimumSize(200, 150)
def mouseMoveEvent(self, event):
# Convert pixel position to a fraction of widget size.
fx = event.position().x() / self.width()
fy = event.position().y() / self.height()
self.crosshair_moved.emit(fx, fy)
def set_crosshair(self, fx, fy):
"""Update the crosshair position and schedule a repaint."""
self.cross_x = fx
self.cross_y = fy
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.fillRect(self.rect(), Qt.GlobalColor.white)
# Draw a border so you can see the widget boundaries.
painter.setPen(QPen(Qt.GlobalColor.gray, 1))
painter.drawRect(self.rect().adjusted(0, 0, -1, -1))
# Draw the crosshair.
px = int(self.cross_x * self.width())
py = int(self.cross_y * self.height())
painter.setPen(QPen(Qt.GlobalColor.red, 1))
painter.drawLine(px, 0, px, self.height())
painter.drawLine(0, py, self.width(), py)
painter.end()
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Synchronized Crosshairs")
layout = QHBoxLayout(self)
# Create several crosshair widgets.
self.widgets = []
for _ in range(4):
w = CrosshairWidget()
self.widgets.append(w)
layout.addWidget(w)
# Connect every widget's signal to every widget's update slot.
for source in self.widgets:
for target in self.widgets:
source.crosshair_moved.connect(target.set_crosshair)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
When you move the mouse over any one of the four widgets, all four crosshairs follow along. Each widget only repaints when set_crosshair is called, which only happens in response to actual mouse movement. No infinite loops, no wasted CPU.
Using paint regions for partial redraws
When your widget has expensive content to draw (complex charts, images, lots of geometry), you can optimize further by only repainting the parts that actually changed. The event parameter passed to paintEvent() is a QPaintEvent object, and it has a region() method that tells you exactly which area of the widget needs updating.
You can also tell update() which region changed by passing a QRect:
from PyQt6.QtCore import QRect
# Only repaint a small area around the old and new crosshair positions.
old_rect = QRect(old_x - 1, 0, 3, self.height())
new_rect = QRect(new_x - 1, 0, 3, self.height())
self.update(old_rect.united(new_rect))
Inside paintEvent, you can then check the region and skip drawing anything outside it. For a simple crosshair widget this level of optimization isn't necessary, but for widgets with heavy drawing it can make a real difference.
Caching the background with QPixmap
Another effective approach is to separate what changes from what stays the same. If your widget draws a complex background (a chart, a grid, an image), you can paint that background once onto a QPixmap and cache it. Then in paintEvent, you draw the cached pixmap first and paint only the crosshair on top. This technique is also useful when building bitmap graphics applications where you need to manage layered painted content efficiently.
from PyQt6.QtWidgets import QApplication, QWidget
from PyQt6.QtGui import QPainter, QPen, QPixmap
from PyQt6.QtCore import Qt
import sys
class CachedCrosshairWidget(QWidget):
def __init__(self):
super().__init__()
self.mouse_x = 0
self.mouse_y = 0
self.setMouseTracking(True)
self.setFixedSize(400, 300)
self._background = None
def _rebuild_background(self):
"""Create the cached background pixmap."""
self._background = QPixmap(self.size())
self._background.fill(Qt.GlobalColor.white)
painter = QPainter(self._background)
painter.setPen(QPen(Qt.GlobalColor.lightGray, 1))
# Draw a grid — imagine this is something expensive.
for x in range(0, self.width(), 20):
painter.drawLine(x, 0, x, self.height())
for y in range(0, self.height(), 20):
painter.drawLine(0, y, self.width(), y)
painter.end()
def resizeEvent(self, event):
# Rebuild the background when the widget size changes.
self._rebuild_background()
def mouseMoveEvent(self, event):
self.mouse_x = int(event.position().x())
self.mouse_y = int(event.position().y())
self.update()
def paintEvent(self, event):
if self._background is None:
self._rebuild_background()
painter = QPainter(self)
# Draw the cached background — this is fast, it's just a pixmap blit.
painter.drawPixmap(0, 0, self._background)
# Draw the crosshair on top — this is the only "live" drawing.
painter.setPen(QPen(Qt.GlobalColor.red, 1))
painter.drawLine(self.mouse_x, 0, self.mouse_x, self.height())
painter.drawLine(0, self.mouse_y, self.width(), self.mouse_y)
painter.end()
app = QApplication(sys.argv)
w = CachedCrosshairWidget()
w.show()
sys.exit(app.exec())
The grid is drawn once and stored in a QPixmap. Each repaint just copies that pixmap (which is very fast) and draws two lines on top. If the grid were a complex chart with thousands of data points, this approach would save a huge amount of work on every mouse move. For data visualization scenarios like this, you might also consider using PyQtGraph which handles efficient real-time plotting out of the box.
PyQt6 Crash Course by Martin Fitzpatrick — The important parts of PyQt6 in bite-size chunks