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.

A VRT points at filesA VRT file of a few kilobytes contains, for each band, a list of sources: a file path, the source window and the destination window in the virtual raster's grid. When QGIS reads a block of the VRT, GDAL works out which source files overlap that block and reads only those parts. No pixels are stored in the VRT itself.An index of sources, not a copy of pixelsmosaic.vrt (4 KB)band 1:tile_01.tif → windowtile_02.tif → window…tile_01.tifread parttile_02.tifread partQGIS seesone raster

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:warpreproject to 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.

Mosaic versus band stackWith SEPARATE false, inputs are placed side by side in one band according to their location, forming a mosaic. With SEPARATE true, each input becomes a band of the same grid, forming a stack such as red, green, blue and near-infrared from four single-band files. Inputs for a stack must cover the same area at the same resolution.Side by side, or on top of each otherSEPARATE = Falsetiles side by sideone bandmosaicSEPARATE = Truefiles on topone band per fileband stack

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.

Overviews for a VRTWithout overviews, a zoomed-out view of a national VRT reads every tile at full resolution and is slow. Building overviews writes reduced-resolution levels, for example 2, 4, 8, 16 and 32, into a sidecar ovr file. QGIS then reads the appropriate level when zoomed out, and the full-resolution tiles only when zoomed in.Zoomed out: read the small copiesno overviewsevery tile readslow when zoomed outgdal:overviews2 · 4 · 8 · 16 · 32sidecar .ovrfast atevery scale

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.