Understanding QTimer Timing and Delays in PyQt6

Why your QTimer might not fire on time, and what you can do about it
Heads up! You've already completed this tutorial.

I have a PyQt script that reads sensor values every three seconds using a QTimer. However, as the script runs longer, the time between QTimer timeouts drifts significantly — sometimes exceeding 40 seconds instead of 3. The GUI also becomes less responsive over time. Why is QTimer not firing on schedule, and what can I do about it?

If you've set up a QTimer to fire every few seconds but noticed that the actual interval between firings is wildly inconsistent, you're not alone. This is a common source of confusion, and understanding why it happens will help you design more reliable PyQt6 applications.

How QTimer Actually Works

A QTimer doesn't operate like a hardware interrupt or a real-time clock. Instead, it works through Qt's event loop. When a timer's interval elapses, Qt places a timer event onto the event queue. Your application then processes that event when it gets to it — but only after it has finished processing whatever else is currently happening.

This means that if your application is busy doing something else — running a Python function, updating a widget, processing data — the timer event has to wait its turn. The timer might expire at the right moment, but your slot won't execute until the event loop is free.

Here's a simple example showing a 3-second timer:

python
import sys
import time
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QTimer Demo")

        self.label = QLabel("Waiting for timer...")
        layout = QVBoxLayout()
        layout.addWidget(self.label)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        self.last_time = time.time()

        self.timer = QTimer()
        self.timer.setInterval(3000)  # 3 seconds
        self.timer.timeout.connect(self.on_timeout)
        self.timer.start()

    def on_timeout(self):
        now = time.time()
        elapsed = now - self.last_time
        self.last_time = now
        self.label.setText(f"Time since last fire: {elapsed:.2f}s")
        print(f"Elapsed: {elapsed:.2f}s")


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

Run this and you'll see the elapsed time reported close to 3.00 seconds each time. So far, so good. The timer fires reliably because the event loop has very little else to do.

What Causes Timer Delays

Now let's simulate a problem. Imagine your timer's callback does some work that takes a while — say, reading from sensors, processing data, or updating plots. If that work blocks the event loop, subsequent timer events pile up and get delayed.

Add a time.sleep() call to simulate slow work:

python
def on_timeout(self):
    now = time.time()
    elapsed = now - self.last_time
    self.last_time = now
    self.label.setText(f"Time since last fire: {elapsed:.2f}s")
    print(f"Elapsed: {elapsed:.2f}s")

    # Simulate slow sensor reading or data processing
    time.sleep(2)

With a 3-second interval and 2 seconds of blocking work, you'll see the effective interval stretch to about 5 seconds. And this compounds: if each cycle takes a little longer (perhaps because a plot has more data points to render), the delays grow over time. This matches the pattern described in the original question — the application getting progressively less responsive.

PyQt/PySide Development Services — Stuck in development hell? I'll help you get your project focused, finished and released. Benefit from years of practical experience releasing software with Python.

Find out More

The same thing happens when you update complex widgets like plots. If your pyqtgraph plot has thousands of data points, each redraw takes longer, and the event loop spends more time on painting than processing timer events.

Strategy 1: Move Work to a Thread

One way to keep the event loop responsive is to move your sensor reading and data processing into a separate thread, using Qt's QThread and signals. For a broader introduction to running background tasks in PyQt6, see Multithreading PyQt6 applications with QThreadPool.

The idea: the timer still fires in the main thread, but instead of doing the heavy work directly, it signals a worker thread to do it. The worker thread sends results back via a signal, and the main thread updates the GUI.

python
import sys
import time
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer, QThread, QObject, pyqtSignal


class SensorWorker(QObject):
    result_ready = pyqtSignal(float)

    def read_sensor(self):
        # Simulate a slow sensor read
        time.sleep(2)
        value = time.time()  # Placeholder for actual sensor data
        self.result_ready.emit(value)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QTimer with Thread")

        self.label = QLabel("Waiting for sensor data...")
        layout = QVBoxLayout()
        layout.addWidget(self.label)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        self.last_time = time.time()

        # Set up the worker thread
        self.thread = QThread()
        self.worker = SensorWorker()
        self.worker.moveToThread(self.thread)
        self.worker.result_ready.connect(self.on_result)
        self.thread.start()

        # Timer triggers the worker
        self.timer = QTimer()
        self.timer.setInterval(3000)
        self.timer.timeout.connect(self.worker.read_sensor)
        self.timer.start()

    def on_result(self, value):
        now = time.time()
        elapsed = now - self.last_time
        self.last_time = now
        self.label.setText(
            f"Sensor value: {value:.2f}\n"
            f"Time since last result: {elapsed:.2f}s"
        )
        print(f"Elapsed: {elapsed:.2f}s")

    def closeEvent(self, event):
        self.thread.quit()
        self.thread.wait()
        super().closeEvent(event)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

With this approach, the 2-second sensor read happens in the background. The main thread's event loop stays free, so the timer fires reliably every 3 seconds and the GUI remains responsive.

A note about Python's GIL: Python's Global Interpreter Lock means that only one thread can execute Python code at a time. However, many I/O operations (like reading from a sensor over a serial port or network) release the GIL while waiting for data. So threads work well for I/O-bound tasks. If your sensor reading involves pure Python computation, threads may not help as much.

Strategy 2: Use a Separate Process

For the most reliable separation between your GUI and your data collection, you can run the sensor reading in a completely separate process using Python's multiprocessing module. This sidesteps the GIL entirely and gives you true parallel execution.

Here's a complete example using multiprocessing with a Queue to pass data back to the GUI:

python
import sys
import time
import multiprocessing
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget
from PyQt6.QtCore import QTimer


def sensor_process(queue, interval):
    """Run in a separate process. Reads sensors and puts results in the queue."""
    while True:
        # Simulate sensor reading
        time.sleep(interval)
        value = time.time()  # Placeholder for real sensor data
        queue.put(value)


class MainWindow(QMainWindow):
    def __init__(self, queue):
        super().__init__()
        self.setWindowTitle("QTimer with Multiprocessing")
        self.queue = queue

        self.label = QLabel("Waiting for sensor data...")
        layout = QVBoxLayout()
        layout.addWidget(self.label)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralLayout = container
        self.setCentralWidget(container)

        self.last_time = time.time()

        # Poll the queue frequently to pick up results
        self.poll_timer = QTimer()
        self.poll_timer.setInterval(500)  # Check every 500ms
        self.poll_timer.timeout.connect(self.check_queue)
        self.poll_timer.start()

    def check_queue(self):
        while not self.queue.empty():
            value = self.queue.get()
            now = time.time()
            elapsed = now - self.last_time
            self.last_time = now
            self.label.setText(
                f"Sensor value: {value:.2f}\n"
                f"Time since last result: {elapsed:.2f}s"
            )
            print(f"Elapsed: {elapsed:.2f}s")


def main():
    queue = multiprocessing.Queue()

    # Start the sensor process
    process = multiprocessing.Process(
        target=sensor_process,
        args=(queue, 3),
        daemon=True,
    )
    process.start()

    app = QApplication(sys.argv)
    window = MainWindow(queue)
    window.show()
    app.exec()

    process.terminate()


if __name__ == "__main__":
    main()

In this design, the sensor reading runs in its own process with its own Python interpreter. It sleeps for the desired interval and puts data into a shared queue. The GUI process polls the queue with a fast, lightweight timer. Because the polling timer's callback does almost no work (just checking a queue), it never blocks the event loop.

Why Timer Drift Gets Worse Over Time

The pattern described in the original question — timers starting out reasonably accurate but degrading over time — is a telltale sign of accumulating work in the main thread. Common causes include:

  • Growing plot data. If you append data to a plot every 3 seconds without limiting the visible range, the plot has more and more points to render. Each redraw takes longer, blocking the event loop for longer.
  • Memory pressure. Growing data structures can cause Python's garbage collector to run more frequently, introducing pauses.
  • Accumulated widget updates. If your GUI triggers repaints or layout recalculations on each update, these can become more expensive as the window state becomes more complex.

To address plot-related slowdowns, consider limiting the number of data points displayed. For example, keep only the most recent 1000 points in your plot data:

python
MAX_POINTS = 1000

def update_plot(self, new_value):
    self.data.append(new_value)
    if len(self.data) > MAX_POINTS:
        self.data = self.data[-MAX_POINTS:]
    self.plot_widget.plot(self.data, clear=True)

Choosing the Right Approach

Here's a quick summary to help you decide:

Approach Best for Limitations
QTimer only (no threading) Light callbacks that return quickly Any slow work blocks the event loop
QTimer + QThread I/O-bound tasks (sensor reads, network requests) Python's GIL limits CPU-bound parallelism
QTimer + multiprocessing CPU-bound work, or when you need guaranteed isolation More complex setup, data must be serializable

For a sensor monitoring application that reads values every few seconds and updates some plots, the QThread approach is usually the best balance of simplicity and reliability. Move the sensor reading into a worker thread, send results back via signals, and keep your plot updates in the main thread. If plot rendering itself becomes the bottleneck, limit the amount of data you're drawing. To learn more about how signals and slots work in PyQt6, see Signals, Slots & Events.

The multiprocessing approach gives the strongest guarantee that your data collection won't be affected by GUI activity, but it adds complexity around inter-process communication.

Complete Working Example

Here's a complete example that combines a worker thread for sensor reading with a pyqtgraph plot, demonstrating reliable timing:

python
import sys
import time
import collections
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QLabel
from PyQt6.QtCore import QTimer, QThread, QObject, pyqtSignal
import pyqtgraph as pg


class SensorWorker(QObject):
    """Reads sensor data in a background thread."""
    reading_ready = pyqtSignal(float, float)  # timestamp, value

    def do_read(self):
        # Simulate a slow sensor read (replace with your actual sensor code)
        time.sleep(1.5)
        timestamp = time.time()
        value = 42.0  # Replace with real sensor value
        self.reading_ready.emit(timestamp, value)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Reliable Sensor Monitor")
        self.resize(800, 400)

        # Data storage — keep the last 200 readings
        self.max_points = 200
        self.timestamps = collections.deque(maxlen=self.max_points)
        self.values = collections.deque(maxlen=self.max_points)
        self.intervals = collections.deque(maxlen=self.max_points)
        self.last_timestamp = None

        # UI
        self.label = QLabel("Starting up...")
        self.plot_widget = pg.PlotWidget(title="Time Between Readings (s)")
        self.plot_widget.setLabel("left", "Interval (s)")
        self.plot_widget.setLabel("bottom", "Reading #")
        self.plot_curve = self.plot_widget.plot(pen="y")

        layout = QVBoxLayout()
        layout.addWidget(self.label)
        layout.addWidget(self.plot_widget)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

        # Worker thread
        self.thread = QThread()
        self.worker = SensorWorker()
        self.worker.moveToThread(self.thread)
        self.worker.reading_ready.connect(self.on_reading)
        self.thread.start()

        # Timer to trigger readings every 3 seconds
        self.timer = QTimer()
        self.timer.setInterval(3000)
        self.timer.timeout.connect(self.worker.do_read)
        self.timer.start()

    def on_reading(self, timestamp, value):
        if self.last_timestamp is not None:
            interval = timestamp - self.last_timestamp
            self.intervals.append(interval)
            self.plot_curve.setData(list(self.intervals))
            self.label.setText(
                f"Latest value: {value:.1f}  |  "
                f"Interval: {interval:.2f}s  |  "
                f"Readings: {len(self.intervals)}"
            )
        self.last_timestamp = timestamp

    def closeEvent(self, event):
        self.timer.stop()
        self.thread.quit()
        self.thread.wait()
        super().closeEvent(event)


def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec()


if __name__ == "__main__":
    main()

In this example, the timer fires every 3 seconds and asks the worker thread to read the sensor. Even though the simulated sensor read takes 1.5 seconds, the main thread stays responsive. The plot updates smoothly, and the interval between readings remains consistent — hovering around 3 seconds rather than drifting upward over time.

If you swap out the time.sleep(1.5) for your actual sensor reading code, you should see stable, predictable timing regardless of how long your application runs. For more on embedding pyqtgraph plots as custom widgets in your PyQt6 applications, see Embedding custom widgets from Qt Designer.

Create GUI Applications with Python & Qt6 by Martin Fitzpatrick — (PyQt6 Edition) The hands-on guide to making apps with Python — Save time and build better with this book. Over 15K copies sold.

Get the book

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak
Martin Fitzpatrick

Understanding QTimer Timing and Delays in PyQt6 was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.