Export Temporal Animation Frames in PyQGIS
Stepping the temporal controller and grabbing the canvas produces a folder of identical images. The canvas renders asynchronously, so a loop that sets a frame and immediately saves gets whatever happened to be on screen — usually the first frame, repeated. Exporting an animation correctly means rendering each frame through a job you can wait on, and that also happens to be the approach that works with no GUI at all.
This recipe belongs to Temporal & 3D Visualization in PyQGIS. It covers a synchronous render per frame, the layout route when you need a legend and a title, running headless, and turning the frames into a video.
Prerequisites
- QGIS 3.34 LTR or newer.
- Layers with temporal properties configured — see setting layer temporal properties.
ffmpegon the path if you want a video at the end.
Render each frame through a job
import os
from qgis.core import (
QgsMapSettings, QgsMapRendererParallelJob, QgsDateTimeRange, QgsProject,
)
from qgis.PyQt.QtCore import QSize, QDateTime, QDate, QTime
project = QgsProject.instance()
layers = [project.mapLayersByName(n)[0] for n in ("depth", "buildings", "basemap")]
settings = QgsMapSettings()
settings.setLayers(layers)
settings.setDestinationCrs(project.crs())
settings.setExtent(layers[0].extent())
settings.setOutputSize(QSize(1920, 1080))
settings.setBackgroundColor(project.backgroundColor())
settings.setIsTemporal(True)
start = QDateTime(QDate(2026, 6, 1), QTime(0, 0))
out_dir = "/data/output/frames"
os.makedirs(out_dir, exist_ok=True)
for frame in range(168):
frame_start = start.addSecs(frame * 3600)
settings.setTemporalRange(QgsDateTimeRange(frame_start, frame_start.addSecs(3600)))
job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save(os.path.join(out_dir, f"frame_{frame:04d}.png"))
Breakdown: setIsTemporal(True) is the switch that makes the render honour the range at all — without it the temporal range is stored and ignored, and every frame comes out identical, which is the same symptom as the canvas bug and a different cause. waitForFinished() is what makes the job synchronous; QgsMapRendererParallelJob still uses several threads internally, so you get the speed without the asynchrony. Zero-padding the filename to four digits is not cosmetic — ffmpeg and most image sequence readers sort lexically, and frame_10.png sorts before frame_2.png.
Reusing one QgsMapSettings across frames and only changing the range is deliberate: constructing it per frame re-resolves layers and re-reads styles for no benefit.
Adding a date label
An animation without a visible clock is much harder to read, and the simplest way to add one is to draw it onto the image.
from qgis.PyQt.QtGui import QPainter, QFont, QColor
image = job.renderedImage()
painter = QPainter(image)
painter.setFont(QFont("Sans", 28, QFont.Bold))
painter.setPen(QColor(20, 20, 20))
painter.drawText(40, 70, frame_start.toString("d MMMM yyyy HH:mm"))
painter.end()
image.save(os.path.join(out_dir, f"frame_{frame:04d}.png"))
Breakdown: QPainter must be ended before the image is saved, or the last drawing operations may not be flushed — an easy omission that produces frames where the label appears intermittently. Drawing after the render rather than as a layer keeps the label crisp at any output size and avoids the label participating in the map's own scaling. For anything more elaborate than a date — a legend, a scale bar, a north arrow — the layout route below is the better answer.
The layout route
from qgis.core import QgsLayoutExporter
layout = project.layoutManager().layoutByName("flood_frame")
map_item = layout.itemById("main_map")
map_item.setIsTemporal(True)
exporter = QgsLayoutExporter(layout)
image_settings = QgsLayoutExporter.ImageExportSettings()
image_settings.dpi = 150
for frame in range(168):
frame_start = start.addSecs(frame * 3600)
map_item.setTemporalRange(
QgsDateTimeRange(frame_start, frame_start.addSecs(3600))
)
exporter.exportToImage(
os.path.join(out_dir, f"layout_{frame:04d}.png"), image_settings
)
Breakdown: itemById finds the map item by the id set in the layout's item properties, so give it one rather than relying on itemsByType and index order. Setting the range on the map item, not on the layout, is what makes the map's layers filter; a label showing the date reads @map_start_time and follows automatically, which is why the layout route needs no QPainter. exportToImage is synchronous, so no waiting is needed. The full mechanics of driving layouts are covered in exporting multiple layouts to PDF.
Running headless
Everything above except the layout's optional GUI dependencies works with no display, because none of it touches the canvas. In a standalone script the only extra work is starting the application and loading the project.
from qgis.core import QgsApplication
QgsApplication.setPrefixPath("/usr", True)
app = QgsApplication([], False)
app.initQgis()
project = QgsProject.instance()
project.read("/data/projects/flood.qgz")
# ... the frame loop from above ...
app.exitQgis()
Breakdown: Passing False as the second argument runs without a GUI, which is what makes this work on a server. The project must be read before the layers are looked up, and the temporal properties travel with the project, so a project configured interactively animates correctly here with no extra setup. Calling exitQgis() matters in a long-running process; in a short script the operating system tidies up anyway. The wider pattern is described in running Python scripts outside QGIS Desktop.
Making a long export survivable
A 500-frame layout export is twenty minutes of work, and twenty minutes is long enough that something will interrupt it. Two small habits make that a non-event.
import time
started = time.monotonic()
for frame in range(total_frames):
path = os.path.join(out_dir, f"frame_{frame:04d}.png")
if os.path.exists(path):
continue
frame_start = start.addSecs(frame * step_seconds)
settings.setTemporalRange(
QgsDateTimeRange(frame_start, frame_start.addSecs(step_seconds))
)
job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save(path)
if frame and frame % 25 == 0:
rate = (time.monotonic() - started) / (frame + 1)
remaining = rate * (total_frames - frame - 1)
print(f"frame {frame}/{total_frames} — about {remaining / 60:.1f} min left")
Breakdown: The existence check makes the loop resumable, which is worth having for the same reason it is in every batch recipe on this site: a re-run after an interruption costs only the frames that are missing. The progress estimate uses elapsed time over completed frames rather than a per-frame measurement, so it smooths over the first frame's cache warm-up and over frames that happen to be heavier than others. Printing every twenty-fifth frame rather than every frame keeps the log readable in a scheduled job's output.
One caveat on resumability: a frame interrupted mid-save leaves a truncated PNG that passes the existence check. Writing to a temporary name and renaming after the save closes that hole, and renaming within one directory is atomic on every platform that matters.
Assembling the video
import subprocess
subprocess.run([
"ffmpeg", "-y",
"-framerate", "12",
"-i", os.path.join(out_dir, "frame_%04d.png"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20",
"/data/output/flood.mp4",
], check=True)
Breakdown: -pix_fmt yuv420p is the setting that makes the file play in browsers and on phones; without it many players show nothing at all. -crf 20 is a quality-versus-size dial where lower is better, and 18 to 23 is the useful range. The %04d pattern must match the zero-padding used when saving. check=True turns an ffmpeg failure into an exception rather than a silent empty file. For an animated GIF instead, a palette pass produces far better results than a direct conversion, at the cost of two commands.
Keeping the frames comparable
An animation is a comparison across time, and three things silently break that comparison if you let them.
The colour classification is the first. A graduated renderer classified on the currently visible features reclassifies as the animation runs, so a value that was orange in frame 10 is yellow in frame 40 and the viewer reads a change that is not in the data. Classify once against the whole dataset's range before starting the loop and leave it alone.
The extent is the second. Any code that zooms to the visible features per frame produces an animation that pans and scales continuously, which makes movement impossible to judge. Set the extent once from the union of everything the animation will show.
The layer set is the third, and it bites specifically with a raster stack: layers whose temporal ranges do not tile exactly leave gaps where nothing is visible and overlaps where two scenes fight. Checking that the ranges tile — that each ends where the next begins — before rendering five hundred frames is thirty seconds well spent.
ranges = sorted(
(l.temporalProperties().fixedTemporalRange(), l.name())
for l in layers if l.temporalProperties().isActive()
)
for (first, name_a), (second, name_b) in zip(ranges, ranges[1:]):
gap = first.end().secsTo(second.begin())
if gap > 0:
print(f"gap of {gap}s between {name_a} and {name_b}")
elif gap < 0:
print(f"overlap of {-gap}s between {name_a} and {name_b}")
Breakdown: Sorting by range puts the layers in temporal order regardless of their order in the tree, which is what makes the pairwise comparison meaningful. Reporting both gaps and overlaps matters because they produce opposite symptoms — a gap is a blank frame, an overlap is a frame where the wrong scene wins — and neither raises an error.
QGIS version compatibility
QgsMapSettings.setIsTemporal and setTemporalRange arrived with the temporal framework in 3.14. QgsLayoutItemMap.setIsTemporal arrived at the same time. QgsMapRendererParallelJob predates all of it and is unchanged. Nothing here differs between 3.34 and 3.44.
Troubleshooting
- Every frame is identical.
setIsTemporal(True)was not set on the map settings or the map item. - Frames are blank. The extent is wrong, or the layers list is empty because the project had not been read yet.
- Frames appear in the wrong order in the video. Filenames are not zero-padded.
- The video will not play. Missing
-pix_fmt yuv420p. - The export is extremely slow. Layout export at high DPI; drop to 96 for a video, where nobody sees the difference.
- Labels appear in some frames only. A
QPainterwas not ended before saving.
Conclusion
Render through a job you can wait on, set setIsTemporal(True), zero-pad the filenames, and choose between bare map settings for speed and a layout for everything else. The same loop runs headless without modification, which means an animation can be a scheduled job rather than an afternoon of clicking.
Frequently Asked Questions
Can I export straight to video from QGIS?
The GUI's animation export writes frames, not video. Assembling with ffmpeg is the standard final step either way.
How do I export a 3D animation? Through a layout containing a 3D map item, setting the temporal range on it per frame exactly as above. The 3D canvas itself has no reliable synchronous capture across releases.
Why is my first frame different from the rest? Layer rendering caches warm up on the first job. Render frame zero twice and discard the first if the difference is visible.
Can I parallelise the frame loop? Not within one process — the render jobs already use the available cores. Splitting frame ranges across separate processes each with its own QGIS application works and scales well.