This guide is part of Raster Mosaicking & Tiling for Pipeline Outputs, within the broader Automated Vector & Raster Cleaning Workflows reference.
Building Virtual Raster Mosaics with GDAL VRT
A VRT presents many rasters as one without duplicating a byte. It is the cheapest possible mosaic, and its cheapness comes entirely from the fact that it defers everything to read time.
What a VRT Gets Right and Wrong
- Right: instant to build. Twelve hundred scenes become one addressable raster in seconds, with no storage cost.
- Right: always current. Reprocess a scene in place and the mosaic reflects it immediately, with no rebuild step.
- Wrong: fragile references. Move the scenes, or let a signed URL expire, and the mosaic silently breaks.
- Wrong: read amplification. A read spanning fifty scenes opens fifty files. Over object storage that is fifty round trips per window.
Version and Environment Compatibility
| Component | Version | Note |
|---|---|---|
| GDAL | >=3.4 |
gdal.BuildVRT with relativeToVRT, /vsis3/ support |
| rasterio | >=1.3 |
Reads VRT transparently; WarpedVRT for the reprojecting case |
| Python | 3.10+ | Union syntax in the recipe |
pip install "rasterio>=1.3" "gdal>=3.4" "geopandas>=1.0"Where the Pixels Actually Come From
The build_scene_vrt Recipe
from __future__ import annotations
import logging
from pathlib import Path
import rasterio
from osgeo import gdal
logger = logging.getLogger(__name__)
gdal.UseExceptions()
def build_scene_vrt(
sources: list[Path],
vrt_path: Path,
nodata: float | int = 0,
resolution: str = "highest",
relative: bool = True,
) -> Path:
"""Build a VRT over pre-ordered sources, failing loudly on incompatible inputs.
`sources` must already be in painting order: GDAL paints later entries over
earlier ones, so the priority winner belongs last in the list.
"""
if not sources:
raise ValueError("no sources supplied for the mosaic")
# Every input must agree on CRS, band count and dtype β a plain VRT cannot reconcile them.
with rasterio.open(sources[0]) as first:
crs, count, dtype = first.crs, first.count, first.dtypes[0]
for path in sources[1:]:
with rasterio.open(path) as src:
if (src.crs, src.count, src.dtypes[0]) != (crs, count, dtype):
raise ValueError(
f"{path.name} differs from the mosaic contract "
f"({src.crs}, {src.count} bands, {src.dtypes[0]})"
)
options = gdal.BuildVRTOptions(
resolution=resolution,
srcNodata=nodata,
VRTNodata=nodata,
addAlpha=False,
resampleAlg="nearest",
)
vrt = gdal.BuildVRT(str(vrt_path), [str(p) for p in sources], options=options)
if vrt is None:
raise RuntimeError(f"BuildVRT produced nothing for {vrt_path}")
vrt.FlushCache()
vrt = None # close before rewriting paths
if relative:
_make_paths_relative(vrt_path)
logger.info("built %s over %d sources", vrt_path.name, len(sources))
return vrt_path
def _make_paths_relative(vrt_path: Path) -> None:
"""Rewrite absolute SourceFilename entries as paths relative to the VRT."""
text = vrt_path.read_text()
base = str(vrt_path.parent) + "/"
text = text.replace(f'relativeToVRT="0">{base}', 'relativeToVRT="1">')
vrt_path.write_text(text)Key Implementation Notes
- Painting order is the list order, and it is the opposite of intuition. GDAL paints sources in sequence, so the highest-priority scene must be last. Reverse the ranked list from the assignment step before passing it in.
- The compatibility loop is not optional.
BuildVRTwill happily produce a mosaic from mixed CRSs and give you nonsense at read time; the explicit check turns that into a build-time error naming the offending file. srcNodataandVRTNodataare both set. The first tells GDAL which source pixels to treat as transparent, the second declares what the mosaic reports. Setting only one produces gaps that read as zeros.- Relative paths make the mosaic movable. A VRT with absolute paths breaks the moment the directory is synced elsewhere, which is a common and confusing failure after a storage migration.
resolution="highest"is a decision, not a default. A mosaic mixing 10 m and 20 m sources will resample the coarse ones up;"average"or an explicitxRes/yResmay be more honest depending on the product.- The VRT is flushed and closed before rewriting. Editing the XML while GDAL still holds the dataset produces a file that is valid on disk and stale in memory.
When to Materialise Instead
A VRT stops being the right answer at three specific points, and recognising them early avoids an awkward migration later.
When the consumer is remote. A VRT over object storage means the reader needs credentials for every source and pays a round trip per contributing scene. A materialised COG per tile turns that into one range request.
When the sources are volatile. Signed URLs expire, lifecycle policies move objects to archive tiers, and scene files get reprocessed under new names. A VRT is a set of promises about paths, and every one of those events breaks a promise.
When reads are dense and repeated. A VRT read is recomputed every time. If the same window is requested daily by a dashboard, materialising it once is cheaper by the second day.
The pragmatic pattern is to use both: a VRT as the working view during processing and for analysts who have direct access to the scenes, and materialised tiles as the published product, as described in writing cloud-optimized GeoTIFFs with rio-cogeo.
Troubleshooting VRT Mosaics
| Symptom | Likely cause | Fix |
|---|---|---|
| Mosaic reads all zeros in gaps | VRTNodata not set |
Set both srcNodata and VRTNodata |
| Wrong scene wins in an overlap | Sources passed in ranked rather than reverse-ranked order | Reverse the list β later entries paint over earlier |
BuildVRT succeeds, reads are nonsense |
Mixed CRSs among sources | Add the compatibility check; reproject first |
| VRT broken after a copy | Absolute source paths | Rewrite with relativeToVRT="1" |
| Reads are extremely slow | Window crosses many small scenes | Materialise tiles, or build intermediate per-region VRTs |
Integration Note
Build the VRT from the ranked manifest produced in raster mosaicking and tiling for pipeline outputs, reversing it so the priority winner paints last. In an orchestrated pipeline the VRT build is cheap enough to run at the end of every ingestion run rather than on a schedule of its own, and because it references rather than copies, rebuilding it is always safe.
Nested VRTs for Very Large Collections
A single VRT over tens of thousands of scenes becomes a problem in itself: the XML grows to tens of megabytes, GDAL parses all of it before serving any read, and every process that opens the mosaic pays that parse.
Nesting fixes it. Build one VRT per region β a UTM zone, a country, a year β and then a top-level VRT over those. A read resolves through two levels of routing instead of scanning one enormous source list, and each regional VRT can be rebuilt independently when its scenes change.
The nesting also matches how corrections arrive. Reprocessing a season of imagery for one country touches one regional VRT; the top-level file is untouched, and consumers holding a reference to it see the change without any coordination.
Keep the nesting shallow. Two levels handle collections into the hundreds of thousands; three is almost always a sign that the product wants materialised tiles rather than a deeper reference tree.