Set Layer Temporal Properties in PyQGIS

Temporal properties are the layer's half of QGIS's time support: they say which field holds a feature's time, how long the feature should be considered present, and whether any of that is switched on. Configure them wrongly and the layer either shows everything all the time or nothing at any time, with no error either way.

This recipe belongs to Temporal & 3D Visualization in PyQGIS. It covers the five vector modes, the raster modes, field types that QGIS will and will not accept as dates, feature duration, and the checks that tell you whether the configuration is doing anything.

Five modes, chosen by the shape of your dataA fixed range applies one interval to the whole layer. An instant per feature marks a single moment. A start and end pair gives each feature its own interval. A start plus a duration field computes the interval. Redraw only re-renders each frame without filtering anything.Match the mode to what the table actually holdsmodeneedson the timelineFixedTemporalRangeone range for the layerFeatureDateTimeInstantFromFieldone date fieldFeatureDateTimeStartAndEndFromFieldstwo date fieldsFeatureDateTimeStartAndDurationFromFieldsa date and a numberRedrawLayerOnlynothingevery mode also needs setIsActive(True) — the properties are ignored without itredraw-only exists for data-defined symbols that read the frame time themselves

Prerequisites

  • QGIS 3.34 LTR or newer. The temporal framework arrived in 3.14 and settled by 3.20.
  • A layer with a date, datetime or ISO-8601 string field.
  • For animation, the controller half — see animating with the temporal controller.

An instant per feature

The commonest case: each row is an event with one timestamp.

from qgis.core import QgsProject, Qgis

incidents = QgsProject.instance().mapLayersByName("incidents")[0]
props = incidents.temporalProperties()

props.setMode(Qgis.VectorTemporalMode.FeatureDateTimeInstantFromField)
props.setStartField("reported_at")
props.setIsActive(True)

Breakdown: setStartField takes the field name, not an index, which is one of the few places in the API where that is true — passing an integer fails silently by matching no field. On QGIS 3.28 and earlier the enum lives at QgsVectorLayerTemporalProperties.ModeFeatureDateTimeInstantFromField; the scoped Qgis.VectorTemporalMode names arrived in 3.30 and both work on current releases. Nothing validates that the named field holds dates: point it at a text column of free-form dates and every feature simply has no time, which renders as an empty map.

An instant has no width, so on any realistic frame duration a feature is visible in the single frame that contains it. Give it one:

from qgis.core import QgsInterval

props.setFixedDuration(4.0)
props.setDurationUnits(Qgis.TemporalUnit.Hours)

Breakdown: This widens every feature's window by the same amount without touching the data, turning a flickering animation into one where patterns are visible. It is a display decision and it changes what the map claims — a four-hour window on incident data implies each incident persisted for four hours, which it did not. Say so in the legend or the caption. Where the true durations vary and are known, the start-and-duration mode below is the honest alternative.

A start and an end

For things that persist — closures, tenancies, permits — two fields give each feature its own interval.

props.setMode(Qgis.VectorTemporalMode.FeatureDateTimeStartAndEndFromFields)
props.setStartField("valid_from")
props.setEndField("valid_to")
props.setIsActive(True)

Breakdown: A null end field means "still open", and QGIS treats it as extending indefinitely, which is exactly the behaviour a live register needs. A null start means the feature never appears — which is the right behaviour but catches people out on datasets where a null start was meant as "always". Filling those with a sentinel far in the past is the usual fix, and doing it in the data rather than in code keeps it visible.

The start-and-duration variant is the same idea where the table holds a length rather than an end:

props.setMode(Qgis.VectorTemporalMode.FeatureDateTimeStartAndDurationFromFields)
props.setStartField("began_at")
props.setDurationField("hours_open")
props.setDurationUnits(Qgis.TemporalUnit.Hours)

Breakdown: The duration units apply to the whole field, so a table mixing minutes and hours in one column cannot be expressed here — compute a single-unit column first. A null or zero duration collapses the feature to an instant, with the flicker problem described above.

What QGIS accepts as a date

Not every column that looks like a date is oneNative date and datetime field types are read directly. Strings in ISO 8601 form are parsed successfully. Integers holding epoch seconds and strings in a local format such as day slash month slash year are not recognised, and produce a layer in which no feature has a time.The failure is silent — no feature simply has a timeread as a dateQDate / QDateTime field"2026-06-04""2026-06-04T10:30:00""2026-06-04 10:30:00"a GeoPackage keeps the native typenot read as a date1780567800 (epoch seconds)"04/06/2026""4 June 2026""20260604"a shapefile flattens datetime to dateconvert once into a real datetime field rather than fighting the parser

Where the column is not in an accepted form, compute a proper one rather than hoping:

from qgis.core import QgsField
from qgis.PyQt.QtCore import QVariant, QDateTime

incidents.startEditing()
if incidents.fields().indexOf("reported_dt") == -1:
    incidents.addAttribute(QgsField("reported_dt", QVariant.DateTime))
    incidents.updateFields()

index = incidents.fields().indexOf("reported_dt")
for feature in incidents.getFeatures():
    stamp = QDateTime.fromSecsSinceEpoch(int(feature["epoch_s"]))
    incidents.changeAttributeValue(feature.id(), index, stamp)
incidents.commitChanges()

Breakdown: QVariant.DateTime is the field type that survives into GeoPackage as a real datetime; a shapefile will silently truncate it to a date, losing the time of day, which is one more reason not to keep temporal data in shapefiles. fromSecsSinceEpoch treats the number as UTC, so if the source is local time you need setTimeSpec afterwards or every feature is offset by the timezone difference — an error that shifts a whole animation and looks entirely plausible. Doing this once as a data preparation step, rather than in the rendering code, is what makes the layer reusable.

Raster layers

Raster temporal properties are simpler, because a raster is one thing at one time.

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

scene = QgsProject.instance().mapLayersByName("s2_20260604")[0]
raster_props = scene.temporalProperties()
raster_props.setMode(Qgis.RasterTemporalMode.FixedTemporalRange)
raster_props.setFixedTemporalRange(QgsDateTimeRange(
    QDateTime(QDate(2026, 6, 4), QTime(10, 25)),
    QDateTime(QDate(2026, 6, 4), QTime(10, 35)),
))
raster_props.setIsActive(True)

Breakdown: A ten-minute window around the acquisition is a sensible convention: wide enough that a frame lands inside it, narrow enough that two scenes from different days never overlap. QgsDateTimeRange includes both ends by default, so back-to-back ranges show both layers for one instant at the boundary; passing includeEnd=False avoids that if it matters. Looping this over a folder of dated scenes, deriving the range from the filename, is a dozen lines and turns an image stack into an animation.

Applying it across a folder of dated files

The raster case scales into a loop, and the interesting part is deriving the range from the filename.

import glob
import os
import re
from qgis.core import QgsRasterLayer

pattern = re.compile(r"(\d{4})(\d{2})(\d{2})")

for path in sorted(glob.glob("/data/scenes/s2_*.tif")):
    match = pattern.search(os.path.basename(path))
    if not match:
        print("skipping, no date in name:", os.path.basename(path))
        continue

    year, month, day = (int(g) for g in match.groups())
    layer = QgsRasterLayer(path, os.path.basename(path))
    temporal = layer.temporalProperties()
    temporal.setMode(Qgis.RasterTemporalMode.FixedTemporalRange)
    temporal.setFixedTemporalRange(QgsDateTimeRange(
        QDateTime(QDate(year, month, day), QTime(0, 0)),
        QDateTime(QDate(year, month, day), QTime(23, 59, 59)),
    ))
    temporal.setIsActive(True)
    QgsProject.instance().addMapLayer(layer)

Breakdown: Reporting the files that did not match, rather than skipping them quietly, is what stops a stray naming convention silently dropping a fortnight out of the animation. A whole-day range per scene is the right choice when the acquisition time is unknown — it guarantees a frame at any time of day finds exactly one scene, provided there is at most one scene per day. Where there are several, narrow the ranges around the real acquisition times or they will overlap and the topmost layer will simply win.

Adding the layers in sorted order matters for a different reason: the layer tree order decides which is drawn on top where ranges do overlap, and a sorted-by-date order at least makes that predictable.

Checking that it works

The single most useful diagnostic is to ask the layer what range it thinks it covers.

extent = incidents.temporalProperties().calculateTemporalExtent(incidents)
print("layer covers", extent.begin(), "to", extent.end())
print("active:", incidents.temporalProperties().isActive())
print("mode:", incidents.temporalProperties().mode())

Breakdown: If the printed extent is empty or invalid, the field is not being read as dates and nothing downstream will work — that check alone resolves most "my animation is blank" reports. If it prints a sensible range and the map is still blank, the problem is on the controller side rather than here. Printing the mode is worth it because an assignment that silently failed (an old enum name on a new build, say) leaves the mode at its default rather than raising.

QGIS version compatibility

Temporal properties arrived in 3.14 and reached their current shape by 3.20. The enum relocation into Qgis.VectorTemporalMode, Qgis.RasterTemporalMode and Qgis.TemporalUnit happened in 3.30; the old class-scoped names remain available. calculateTemporalExtent has been present throughout. Mesh temporal properties differ in kind and are covered in loading and styling a mesh layer.

Troubleshooting

  • Nothing is filtered at all. setIsActive(True) was not called, or the mode is RedrawLayerOnly.
  • The map is empty at every frame. The date field is not being parsed — check calculateTemporalExtent.
  • Features flash for one frame. Instant mode with no duration. Set a fixed duration or use start-and-end.
  • Everything is shifted by a few hours. Timezone: epoch values read as UTC when they were local, or the reverse.
  • A feature never appears despite a valid end date. Its start is null, which means "never" rather than "always".
  • Times lost after export. The output format is shapefile, which has no datetime type.

Conclusion

Choose the mode from what the table holds, name the fields as strings, remember setIsActive(True), and give instants a duration if you want them to be visible. Then confirm with calculateTemporalExtent before blaming the controller. Almost every blank temporal map is one of those four things.

Frequently Asked Questions

Can different layers use different modes in one project? Yes. Properties are per layer, and the controller's range is applied to each according to its own mode, which is how an events layer and a raster stack animate together.

Do temporal properties affect Processing algorithms? No. They filter rendering, not the feature source, so an algorithm reading the layer sees every feature regardless of the current frame.

How do I copy temporal settings between layers? There is no dedicated copy method, but the settings are part of the layer style, so saveNamedStyle/loadNamedStyle carries them — see saving and loading a QML style.

Can the duration come from an expression rather than a field? Not directly. Compute the value into a real or virtual field and point the duration field at that.