Filter Features by Time Range in PyQGIS
Most real data has a time dimension: incidents with a date, permits valid from one date to another, sensor readings with timestamps, buildings with construction and demolition years. Maps and analyses usually need one slice of it — last month's incidents, permits valid on a given day, buildings standing in 1950. QGIS offers several ways to filter by time, from a simple subset string to the temporal controller, and choosing the right one depends on whether the filter is a fixed part of the analysis or something the map viewer should change.
This recipe belongs to Temporal & 3D Visualization. It filters by instants and by intervals with subset strings and feature requests, writes robust date expressions, sets a fixed range on the temporal controller, handles time zones, and summarises what was active in each period.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A layer with real date or datetime fields. Dates stored as text must be converted first, as in changing a field type, or parsed inside expressions.
Filter instants with a subset string
For a fixed analysis — "incidents in Q3 2026" — a subset string on the layer is the simplest filter. It is pushed to the data provider, so databases do the work.
from qgis.core import QgsProject
incidents = QgsProject.instance().mapLayersByName("incidents")[0]
ok = incidents.setSubsetString(
"\"reported_at\" >= '2026-07-01' AND \"reported_at\" < '2026-10-01'")
print(ok, incidents.featureCount(), "incidents in Q3 2026")
Breakdown: A half-open window — start inclusive, end exclusive — is the robust way to express a period: everything on or after 1 July and before 1 October, with no ambiguity about the last moment of 30 September. Subset strings use the provider's SQL dialect for file and database sources, so ISO date literals in quotes work for GeoPackage, PostGIS and most others. Clear the filter with setSubsetString(""). The filter changes what the layer contains for every tool — map, attribute table, Processing — which is what you want for a fixed analysis and not what you want for interactive browsing.
Filter with expressions and feature requests
Expression filters work on any provider and support QGIS's date functions — relative periods, components such as weekday or hour, and parsing of text dates.
from qgis.core import QgsFeatureRequest
last_30_days = QgsFeatureRequest().setFilterExpression(
"\"reported_at\" >= now() - to_interval('30 days')")
weekend_nights = QgsFeatureRequest().setFilterExpression(
"day_of_week(\"reported_at\") IN (0, 6) AND hour(\"reported_at\") >= 22")
recent = list(incidents.getFeatures(last_30_days))
print(len(recent), "in the last 30 days;",
sum(1 for _ in incidents.getFeatures(weekend_nights)), "on weekend nights")
Breakdown: now() - to_interval('30 days') defines a rolling window, so a scheduled report always shows the latest month without editing dates. Component functions answer questions a simple range cannot — weekend nights, a particular month across years. In QGIS expressions, day_of_week returns 0 for Sunday and 6 for Saturday. Expression filters run in QGIS rather than in the database unless the provider can translate them, so for very large tables combine a broad subset string with a finer expression. More on date functions in evaluating QGIS expressions.
Filter intervals by overlap
Features with a start and end — permits, leases, construction projects — match a window when their interval overlaps it. The overlap test needs both ends and care with open-ended records.
permits = QgsProject.instance().mapLayersByName("building_permits")[0]
A, B = "2026-09-01", "2026-10-01"
active = QgsFeatureRequest().setFilterExpression(
f"\"valid_from\" < '{B}' AND coalesce(\"valid_to\", to_date('9999-12-31')) > '{A}'")
ids = [f.id() for f in permits.getFeatures(active)]
permits.selectByIds(ids)
print(len(ids), "permits active in September 2026")
Breakdown: An interval overlaps the window if it starts before the window ends and ends after the window starts. Permits with no end date are still running, so coalesce substitutes a far-future date; without it, NULL ends make the comparison NULL and those permits silently drop out. The common mistake — testing only whether the start date falls inside the window — misses everything that began earlier and is still active, which is often most of the records. Selecting the matching features is a convenient way to inspect the result on the map.
Use the temporal controller for a fixed range
When the map should show a time window — and users may change it later — layer temporal properties plus the temporal controller are the better tool. The layer stays complete; the controller decides what is drawn.
from qgis.core import QgsDateTimeRange, QgsVectorLayerTemporalProperties, QgsInterval
from qgis.PyQt.QtCore import QDateTime
from qgis.utils import iface
props = permits.temporalProperties()
props.setIsActive(True)
props.setMode(QgsVectorLayerTemporalProperties.ModeFeatureDateTimeStartAndEndFromFields)
props.setStartField("valid_from")
props.setEndField("valid_to")
controller = iface.mapCanvas().temporalController()
controller.setNavigationMode(controller.FixedRange)
controller.setTemporalExtents(QgsDateTimeRange(
QDateTime.fromString("2026-09-01T00:00:00", "yyyy-MM-ddTHH:mm:ss"),
QDateTime.fromString("2026-10-01T00:00:00", "yyyy-MM-ddTHH:mm:ss")))
iface.mapCanvas().refresh()
Breakdown: With start and end fields configured, QGIS applies the overlap logic itself, including features with NULL ends, which it treats as open-ended. A fixed-range controller filters every temporally enabled layer on the canvas to the same window, so incidents, permits and roadworks can be viewed together for one month. The layer's data is untouched — the attribute table still shows everything — which suits exploration. For animations and frame exports, see setting layer temporal properties and animating with the temporal controller.
Handle time zones
Timestamps from sensors and web services are often in UTC; reports are usually wanted in local time. A window of "3 March" means different instants in each.
from datetime import datetime
from zoneinfo import ZoneInfo
local = ZoneInfo("Europe/Berlin")
start_local = datetime(2026, 3, 3, 0, 0, tzinfo=local)
end_local = datetime(2026, 3, 4, 0, 0, tzinfo=local)
start_utc = start_local.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M:%S")
end_utc = end_local.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M:%S")
readings = QgsProject.instance().mapLayersByName("sensor_readings")[0]
readings.setSubsetString(f"\"observed_utc\" >= '{start_utc}' AND \"observed_utc\" < '{end_utc}'")
print("local day 3 March =", start_utc, "to", end_utc, "UTC;", readings.featureCount(), "readings")
Breakdown: Converting the local window boundaries to UTC before filtering keeps the comparison in the data's own time zone; converting every stored value instead would be slower and error-prone. Python's zoneinfo handles daylight-saving changes, which matter twice a year when a local day is 23 or 25 hours long. Store timestamps in UTC and record that convention in the field name or metadata, as here with observed_utc.
Summarise what was active per period
Time filters are often a step towards a table: how many permits were active each month, how many incidents per week. Looping over periods with the overlap filter produces it.
from datetime import date
def month_bounds(year, month):
start = date(year, month, 1)
end = date(year + (month == 12), (month % 12) + 1, 1)
return start.isoformat(), end.isoformat()
series = []
for m in range(1, 13):
a, b = month_bounds(2026, m)
req = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry).setFilterExpression(
f"\"valid_from\" < '{b}' AND coalesce(\"valid_to\", to_date('9999-12-31')) > '{a}'")
series.append((a[:7], sum(1 for _ in permits.getFeatures(req))))
for month, n in series:
print(month, n)
Breakdown: Each month gets half-open bounds, and the same overlap condition counts permits active at any point in that month — a permit spanning three months counts in all three, which is the right answer for "active" and the wrong one for "issued"; for issued counts, filter on valid_from alone. Skipping geometry makes each count fast. The series plots directly with matplotlib, as in plotting layer data.
QGIS version compatibility
Subset strings, expression date functions and vector temporal properties work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, QgsVectorLayerTemporalProperties.ModeFeatureDateTimeStartAndEndFromFields is Qgis.VectorTemporalMode.FeatureDateTimeStartAndEndFromFields and the controller's navigation mode is Qgis.TemporalNavigationMode.FixedRange.
Troubleshooting
- Nothing matches. The date field is text in a non-ISO format; convert it or parse with
to_date(field, format). - Open-ended records are missing. NULL end dates were not handled; use
coalesce. - Counts are off by a day. Inclusive end boundaries or time-zone mismatch; use half-open windows in the data's time zone.
- The temporal controller shows everything. The layer's temporal properties are not active or the mode does not match the fields.
Conclusion
Use half-open windows, subset strings for fixed analyses and expression filters for relative or component-based ones, the overlap test with coalesce for intervals, the temporal controller when the map viewer should control the window, UTC conversion for local-time questions, and one filter per period for time series.
Frequently Asked Questions
Can I filter raster layers by time? Yes, through raster temporal properties when the raster or its provider carries time information, such as WMS-T or netCDF.
Does the temporal controller affect Processing? No. Processing reads the full layer; apply a subset or expression filter for analysis.
How do I filter by year only?year("reported_at") = 2026 — simple, though a range on the full date can use database indexes.
How do I find features whose dates are impossible?
Filter for intervals that end before they start — "valid_to" < "valid_from" — or dates in the future where they should not be; such records break every overlap test and are worth fixing at the source.
Can a project variable define the window?
Yes — reference @report_start and @report_end in the expression and set them per run.