Build a Virtual Raster (VRT) in PyQGIS
A virtual raster is a small XML file that describes how to assemble other raster files into one. QGIS and every GDAL-based tool open it like any GeoTIFF, but nothing is copied: reading a pixel from the VRT reads it from the right source file on the fly. That makes VRTs the quickest way to treat a folder of DEM tiles as one elevation model, to stack separate band files from a satellite product into one multiband image, or to give an analysis a stable layer name while the underlying tiles are updated.
This recipe belongs to Raster Analysis Workflows. It builds mosaic and band-stack VRTs with Processing, chooses resolution and NoData handling, keeps paths portable, adds overviews for fast display, and turns a VRT into a real file when a standalone copy is needed.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series, with the GDAL Processing provider.
- Source rasters in the same CRS. A mosaic VRT cannot reproject; reproject first, or use
gdal:warpreprojectto write a warped VRT.
Build a mosaic VRT
The most common VRT stitches tiles into one seamless raster. gdal:buildvirtualraster takes a list of inputs and writes the XML.
from pathlib import Path
import processing
from qgis.core import QgsRasterLayer, QgsProject
tiles = sorted(str(p) for p in Path("/data/dem/tiles").glob("dem_*.tif"))
vrt = processing.run("gdal:buildvirtualraster", {
"INPUT": tiles,
"RESOLUTION": 0, # 0 = average, 1 = highest, 2 = lowest
"SEPARATE": False,
"PROJ_DIFFERENCE": False,
"ADD_ALPHA": False,
"ASSIGN_CRS": None,
"RESAMPLING": 0,
"SRC_NODATA": "-9999",
"OUTPUT": "/data/dem/dem_all.vrt",
})["OUTPUT"]
dem = QgsRasterLayer(vrt, "DEM (all tiles)")
print(dem.isValid(), dem.width(), "x", dem.height(), dem.crs().authid())
QgsProject.instance().addMapLayer(dem)
Breakdown: With SEPARATE false, all inputs go into the same band, placed by their georeferencing — a mosaic. RESOLUTION decides the VRT's pixel size when tiles differ; for uniform tiles it makes no difference, and for mixed tiles "highest" keeps detail at the cost of resampling coarse tiles. SRC_NODATA declares which source value is empty, so gaps and tile edges stay transparent and later tiles do not overwrite earlier ones with empty pixels. PROJ_DIFFERENCE false makes the build refuse tiles in a different CRS, which is safer than silently mixing them. The result opens instantly regardless of how many gigabytes the tiles hold.
Stack bands from separate files
Many satellite products deliver each band as its own file. A VRT with SEPARATE set puts each input in its own band, producing a multiband image without copying anything.
scene = Path("/data/s2/S2B_20260614_T33UUU")
bands = [str(scene / f"B{b}_10m.jp2") for b in ("04", "03", "02", "08")] # R, G, B, NIR
stack = processing.run("gdal:buildvirtualraster", {
"INPUT": bands, "SEPARATE": True, "RESOLUTION": 1,
"SRC_NODATA": "0", "OUTPUT": str(scene / "rgbn.vrt")})["OUTPUT"]
img = QgsRasterLayer(stack, "Sentinel-2 RGBN")
print(img.bandCount(), "bands")
Breakdown: Band order follows the input list, so listing red, green, blue, near-infrared gives a stack where bands 1–3 render as a natural-colour image by default. All inputs should share extent and resolution; if they differ — Sentinel-2's 20 m bands next to 10 m ones — RESOLUTION: 1 resamples to the finest. Band names can be added by editing the Description element of each band in the VRT XML, which makes the stack self-documenting in QGIS's layer properties. The stacked VRT can feed index calculations, as in using rasterio and NumPy with QGIS rasters.
Keep paths portable
A VRT stores paths to its sources. Absolute paths break when the folder moves or is shared; relative paths keep the VRT working wherever the folder goes, as long as the VRT moves with its sources.
import xml.etree.ElementTree as ET
import os
def make_relative(vrt_path):
tree = ET.parse(vrt_path)
base = os.path.dirname(os.path.abspath(vrt_path))
changed = 0
for src in tree.iter("SourceFilename"):
if src.get("relativeToVRT") == "0" and os.path.isabs(src.text):
src.text = os.path.relpath(src.text, base)
src.set("relativeToVRT", "1")
changed += 1
tree.write(vrt_path)
return changed
print(make_relative("/data/dem/dem_all.vrt"), "paths made relative")
Breakdown: Each source in the VRT XML is a SourceFilename element with a relativeToVRT flag. Rewriting absolute paths relative to the VRT's own folder and setting the flag makes the VRT portable. GDAL writes relative paths automatically when the sources are below the VRT's folder, so placing the VRT next to or above the tile folder avoids the need for this fix. A VRT referencing files on a network share by drive letter is the classic case that breaks for colleagues — relative paths solve it.
Add overviews for fast display
A VRT over hundreds of tiles must open every tile to draw a zoomed-out view, which is slow. Overviews — pre-computed lower-resolution copies — make it fast. For a VRT they are stored in a sidecar .ovr file.
processing.run("gdal:overviews", {
"INPUT": "/data/dem/dem_all.vrt",
"CLEAN": False,
"LEVELS": "2 4 8 16 32",
"RESAMPLING": 1, # average
"FORMAT": 1, # external .ovr
"EXTRA": "--config COMPRESS_OVERVIEW DEFLATE",
})
Breakdown: Levels 2 to 32 cover typical zoom ranges for a national DEM; add 64 or 128 for very large mosaics. Averaging is right for continuous data like elevation; use nearest neighbour for categorical rasters such as land cover, where averaging class codes produces meaningless values. External overviews leave the source tiles untouched. Rebuild overviews when tiles change, since they are not updated automatically.
Build a VRT for one area from a tile index
For a national tile set, building one VRT over everything is fine for display, but analyses of a single catchment run faster against a VRT of just the tiles they need. A tile index — a polygon layer with one footprint per file — makes selecting them a spatial query.
index = processing.run("gdal:tileindex", {
"LAYERS": tiles, "PATH_FIELD_NAME": "location", "ABSOLUTE_PATH": True,
"OUTPUT": "/data/dem/dem_tiles_index.gpkg"})["OUTPUT"]
index_layer = QgsVectorLayer(index, "tile index", "ogr")
catchment = QgsProject.instance().mapLayersByName("catchment")[0]
picked = processing.run("native:extractbylocation", {
"INPUT": index_layer, "PREDICATE": [0], "INTERSECT": catchment,
"OUTPUT": "memory:"})["OUTPUT"]
paths = [f["location"] for f in picked.getFeatures()]
local = processing.run("gdal:buildvirtualraster", {
"INPUT": paths, "SEPARATE": False, "SRC_NODATA": "-9999",
"OUTPUT": "/data/dem/dem_catchment.vrt"})["OUTPUT"]
print(len(paths), "tiles in the catchment VRT")
Breakdown: gdal:tileindex writes one polygon per raster with its path in a field, and it only needs rebuilding when tiles are added. Selecting footprints that intersect the catchment returns exactly the files to include, and a VRT over those is small, fast to open and fast to process. The tile index is useful in its own right — styled over a basemap it shows coverage and gaps at a glance. Add from qgis.core import QgsVectorLayer when running this section alone.
Convert a VRT into a real file
Sometimes a standalone file is needed after all: to send to someone, to archive, or for a tool that does not read VRTs. gdal:translate reads the VRT and writes a GeoTIFF in one step.
processing.run("gdal:translate", {
"INPUT": "/data/dem/dem_all.vrt",
"OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=3|TILED=YES|BIGTIFF=IF_SAFER",
"DATA_TYPE": 0, # keep input type
"OUTPUT": "/data/delivery/dem_mosaic.tif",
})
Breakdown: Translating a VRT is equivalent to merging the tiles, with the advantage that you have already inspected the VRT in QGIS and know the result is right. Combine it with a PROJWIN extent to export just a study area. For cloud delivery, write a Cloud Optimized GeoTIFF instead, as in exporting a COG. The merge route is compared in merging raster tiles into a mosaic.
QGIS version compatibility
gdal:buildvirtualraster, gdal:overviews and gdal:translate work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Parameter names for source NoData and resampling have been stable across these releases; check processing.algorithmHelp(...) for the enum orders on your version. VRT reading behaviour depends on the bundled GDAL, which is 3.8 or newer on current releases.
Troubleshooting
- The VRT opens but shows nothing. Source paths are wrong, usually absolute paths after a move; make them relative.
- Tiles are missing from the VRT. They had a different CRS and were skipped; reproject them.
- Black frames around tiles. Source NoData was not declared.
- Zoomed-out views are slow. Build overviews.
Conclusion
Build mosaic VRTs to treat tiles as one raster and band-stack VRTs to combine separate band files, declare source NoData, keep sources in one CRS, make paths relative so the VRT travels, add overviews for fast display, and translate to a GeoTIFF or COG when a standalone file is required.
Frequently Asked Questions
Can a VRT reproject?
A warped VRT can, written with gdalwarp -of VRT; the mosaic VRT built here cannot.
Can I edit a VRT by hand? Yes; it is XML. Change paths, band descriptions or NoData with any editor, and keep a backup.
Are VRTs slower than GeoTIFFs? Slightly, especially over many small files or network drives; overviews and local storage close most of the gap.
Can I store a VRT inside a QGIS project? The project stores the VRT's path like any raster source. Keep the VRT next to the project, or in a shared data folder with relative source paths, so the project opens on other machines without broken layers.
Can Processing algorithms use VRTs as input? Yes. Any algorithm that reads rasters through GDAL accepts a VRT.