Animate Layers with the Temporal Controller in PyQGIS

The temporal controller is the object that decides when the map is. It holds an overall extent, a frame duration, a current frame, and it emits a signal every time that frame changes so that everything else — the canvas, a title label, a chart in a dock — can follow along. Layers do the filtering; the controller does the timekeeping.

This recipe belongs to Temporal & 3D Visualization in PyQGIS. It covers reaching the controller, setting extents and frame duration, stepping versus playing, connecting to the frame signal, and driving the animation from a plugin.

Frame number in, date range outThe controller holds an overall temporal extent and a frame duration. Dividing one by the other gives the total frame count. The current frame number is multiplied by the duration and added to the extent's start to give the range for that frame, which every time-aware layer then filters against.The controller does arithmetic; the layers do the filteringcontroller stateextents: 1 Jun → 8 JunframeDuration: 1 hourcurrentFrame: 37computed range2 Jun 13:00→ 2 Jun 14:00start + 37 × durationeach layer filtersby its own modeand its own fieldsthen the canvas redrawstotalFrameCount = extent length ÷ frame duration7 days at 1 hour = 168 frames · 7 days at 1 minute = 10,080 framescheck the count before you press play — it is easy to ask for ten thousand renders

Prerequisites

  • QGIS 3.34 LTR or newer, running with a GUI — the controller belongs to the map canvas.
  • At least one layer with temporal properties configured — see setting layer temporal properties.

Reaching the controller and setting it up

from qgis.core import QgsDateTimeRange, QgsInterval, Qgis
from qgis.PyQt.QtCore import QDateTime, QDate, QTime

controller = iface.mapCanvas().temporalController()

controller.setTemporalExtents(QgsDateTimeRange(
    QDateTime(QDate(2026, 6, 1), QTime(0, 0)),
    QDateTime(QDate(2026, 6, 8), QTime(0, 0)),
))
controller.setFrameDuration(QgsInterval(1, Qgis.TemporalUnit.Hours))

print(controller.totalFrameCount(), "frames")

Breakdown: temporalController() returns the canvas's own QgsTemporalNavigationObject, which is shared with the Temporal Controller panel — set it from Python and the panel updates, and vice versa. QgsInterval takes a magnitude and a unit; on QGIS 3.28 and earlier the unit enum is QgsUnitTypes.TemporalHours rather than Qgis.TemporalUnit.Hours. totalFrameCount() is derived, not stored, so it changes the moment either input does — printing it before doing anything else is the cheap way to notice you have asked for ten thousand frames.

Deriving the extent from the data, rather than hard-coding it, is almost always what you want:

layer = iface.activeLayer()
extent = layer.temporalProperties().calculateTemporalExtent(layer)
if extent.isEmpty():
    raise SystemExit("layer has no usable temporal extent")
controller.setTemporalExtents(extent)

Breakdown: calculateTemporalExtent scans the layer's time fields and returns the range they span, so the animation covers exactly the data and no more. It is a full scan, so on a large layer it is worth doing once and caching rather than on every configuration change. The empty check matters because an empty extent gives a frame count of zero and an animation that does nothing, with no error.

Stepping versus playing

Two very different modes, and scripts almost always want the first.

controller.setCurrentFrameNumber(0)

for frame in range(controller.totalFrameCount()):
    controller.setCurrentFrameNumber(frame)
    print(frame, controller.dateTimeRangeForFrameNumber(frame).begin())

Breakdown: setCurrentFrameNumber sets the frame and triggers the canvas to refresh, but it does not block until the refresh completes — the render is asynchronous, so a loop like this races ahead and the canvas only ever shows the last frame. That is fine when you are stepping to inspect state, and fatal when you are capturing images, which is why exporting animation frames renders through a job object instead of through the canvas.

controller.setNavigationMode(
    Qgis.TemporalNavigationMode.Animated
)
controller.setFramesPerSecond(8)
controller.setLooping(True)
controller.play()

Breakdown: play() starts a timer and returns immediately, so it is only useful in an interactive session or a plugin — a script that calls it and then exits kills the animation with the interpreter. setFramesPerSecond is a request rather than a guarantee: if a frame takes longer than the interval to render, the animation simply runs slower, which is the sane behaviour and means a heavy project animates at whatever speed it can manage. setLooping(True) restarts at the beginning rather than stopping at the end.

Reacting to the frame changing

One signal, many followersWhen the controller's frame changes it emits updateTemporalRange carrying the new range. The canvas redraws, but anything else that should follow the animation — a date label, a chart cursor, a plugin panel — has to connect to that signal itself.Only the canvas follows automaticallycontrollerupdateTemporalRangemap canvasconnected alreadyredraws itselfdate labelyou connect itchart cursoryou connect itplugin panelyou connect itdisconnect on plugin unload or the slot outlives its widget

def on_range_changed(temporal_range):
    begin = temporal_range.begin()
    print("now showing", begin.toString("yyyy-MM-dd HH:mm"))

controller.updateTemporalRange.connect(on_range_changed)

Breakdown: The signal carries the new QgsDateTimeRange, so the slot needs no lookups. It fires on every frame change from any source — the panel's play button, a script call, a keyboard step — which is exactly what makes it the right place to keep a label or a chart in sync. In a plugin, connect in initGui and disconnect in unload; a slot bound to a deleted widget is the classic cause of a crash on plugin reload, and the mechanics of that are covered in understanding signals and slots.

Driving it from a plugin

A plugin that owns an animation usually wants to configure the controller once and then step it in response to its own controls.

from qgis.PyQt.QtCore import QTimer

class FloodAnimator:
    def __init__(self, canvas):
        self.controller = canvas.temporalController()
        self.controller.updateTemporalRange.connect(self.on_range)
        self.timer = QTimer()
        self.timer.timeout.connect(self.advance)

    def start(self, interval_ms=200):
        self.controller.setCurrentFrameNumber(0)
        self.timer.start(interval_ms)

    def advance(self):
        frame = self.controller.currentFrameNumber() + 1
        if frame >= self.controller.totalFrameCount():
            self.timer.stop()
            return
        self.controller.setCurrentFrameNumber(frame)

    def on_range(self, temporal_range):
        pass  # update the plugin's own labels here

    def teardown(self):
        self.timer.stop()
        self.controller.updateTemporalRange.disconnect(self.on_range)

Breakdown: Driving with your own QTimer rather than play() gives you the stop condition, the interval and the ability to do work between frames — which the built-in playback does not offer. Keeping a reference to the timer on the instance is not optional: a QTimer created as a local goes out of scope and stops firing, which is one of the most common and most confusing bugs in PyQGIS plugin code. teardown disconnecting the signal is what keeps a plugin reloadable.

Choosing a frame duration

The frame duration is the decision that shapes the animation, and it is worth thinking about in two directions at once: what the data can support, and what a viewer can absorb.

From the data's side, a frame shorter than the resolution of the underlying observations is invention. Hourly rain gauge readings animated at one-minute frames produce sixty identical frames per reading and an animation that appears to stutter. A frame duration equal to the observation interval is the natural floor.

From the viewer's side, an animation that runs longer than about thirty seconds loses its audience, and one with more than a few hundred frames is expensive to render. Dividing the total span by a target of 150 to 300 frames gives a duration that is usually about right, and rounding it to a human unit — an hour, a day, a week — makes the date labels readable.

span_seconds = extent.begin().secsTo(extent.end())
for magnitude, unit, seconds in (
    (1, Qgis.TemporalUnit.Hours, 3600),
    (6, Qgis.TemporalUnit.Hours, 21600),
    (1, Qgis.TemporalUnit.Days, 86400),
    (1, Qgis.TemporalUnit.Weeks, 604800),
):
    frames = span_seconds / (magnitude * seconds)
    print(f"{magnitude} × {unit}: {frames:.0f} frames")

Breakdown: Printing the candidates rather than computing one answer respects that this is a judgement rather than an optimisation — a flood model wants hourly frames over three days, and a land-cover change animation wants annual frames over thirty years, and no formula distinguishes them. What the loop does usefully is stop you discovering the frame count after starting a render.

Where the data is dense but the interesting part is short, two animations beat one compromise: a coarse overview across the whole span and a fine one across the event.

QGIS version compatibility

QgsTemporalNavigationObject and mapCanvas().temporalController() have been present since 3.14. The navigation-mode and temporal-unit enums moved into the scoped Qgis namespace in 3.30, with the old QgsUnitTypes.TemporalHours and QgsTemporalNavigationObject.Animated names still working. dateTimeRangeForFrameNumber has been stable throughout and is the supported way to convert a frame to a range without changing the current frame.

Troubleshooting

  • temporalController() returns None. No GUI — there is no controller in a headless script.
  • The frame count is zero. The extent is empty or the frame duration is longer than the extent.
  • Only the last frame is drawn in a loop. Canvas rendering is asynchronous; render through a job object to capture frames.
  • play() does nothing in a script. The interpreter exits before the timer fires. Use an interactive session or a plugin.
  • The animation is far slower than the requested frame rate. Rendering is the bottleneck; simplify the project or lengthen the frame duration.
  • A crash on plugin reload. A slot still connected to updateTemporalRange after its object was destroyed.

Conclusion

Set the extent from the data, check the frame count before anything else, step with setCurrentFrameNumber when you need control and play() only in an interactive context, and connect to updateTemporalRange for anything that must follow the animation. The controller is small and predictable; nearly every problem with it is a frame count nobody looked at.

Frequently Asked Questions

Can I animate a 3D view with the same controller? Yes — a 3D map canvas honours the same temporal range, so a single controller drives both views in step.

How do I show the current date on the map? A layout label with the @map_start_time variable, or a canvas decoration updated from the updateTemporalRange slot. The layout route survives an export.

Does the controller affect layers whose temporal properties are inactive? No. An inactive layer draws identically at every frame, which is how you keep a basemap visible throughout.

Can the frame duration be irregular? No. Frames are uniform. Irregular time steps mean rendering per timestamp yourself rather than using the frame model.